Reduce overhead to load multiple languages in translations (#111028)
* Reduce overhead to load multiple languages in translations Instead of loading in a task, we now group everything to be loaded into a single executor job * fixes * fixes * fixes * fixes * fixes * update tests * add missing coverage (was existing)pull/111054/head
parent
9c145b5faa
commit
9ce1ec414e
|
@ -72,23 +72,27 @@ def component_translation_path(
|
|||
return str(translation_path / filename)
|
||||
|
||||
|
||||
def load_translations_files(
|
||||
translation_files: dict[str, str],
|
||||
def _load_translations_files_by_language(
|
||||
translation_files: dict[str, dict[str, str]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Load and parse translation.json files."""
|
||||
loaded = {}
|
||||
for component, translation_file in translation_files.items():
|
||||
loaded_json = load_json(translation_file)
|
||||
loaded: dict[str, dict[str, Any]] = {}
|
||||
for language, component_translation_file in translation_files.items():
|
||||
loaded_for_language: dict[str, Any] = {}
|
||||
loaded[language] = loaded_for_language
|
||||
|
||||
if not isinstance(loaded_json, dict):
|
||||
_LOGGER.warning(
|
||||
"Translation file is unexpected type %s. Expected dict for %s",
|
||||
type(loaded_json),
|
||||
translation_file,
|
||||
)
|
||||
continue
|
||||
for component, translation_file in component_translation_file.items():
|
||||
loaded_json = load_json(translation_file)
|
||||
|
||||
loaded[component] = loaded_json
|
||||
if not isinstance(loaded_json, dict):
|
||||
_LOGGER.warning(
|
||||
"Translation file is unexpected type %s. Expected dict for %s",
|
||||
type(loaded_json),
|
||||
translation_file,
|
||||
)
|
||||
continue
|
||||
|
||||
loaded_for_language[component] = loaded_json
|
||||
|
||||
return loaded
|
||||
|
||||
|
@ -151,47 +155,53 @@ def build_resources(
|
|||
|
||||
async def _async_get_component_strings(
|
||||
hass: HomeAssistant,
|
||||
language: str,
|
||||
languages: Iterable[str],
|
||||
components: set[str],
|
||||
integrations: dict[str, Integration],
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Load translations."""
|
||||
translations: dict[str, Any] = {}
|
||||
translations_by_language: dict[str, dict[str, Any]] = {}
|
||||
# Determine paths of missing components/platforms
|
||||
files_to_load: dict[str, str] = {}
|
||||
for loaded in components:
|
||||
domain = loaded.partition(".")[0]
|
||||
if not (integration := integrations.get(domain)):
|
||||
continue
|
||||
files_to_load_by_language: dict[str, dict[str, str]] = {}
|
||||
for language in languages:
|
||||
files_to_load: dict[str, str] = {}
|
||||
files_to_load_by_language[language] = files_to_load
|
||||
|
||||
path = component_translation_path(loaded, language, integration)
|
||||
# No translation available
|
||||
if path is None:
|
||||
translations[loaded] = {}
|
||||
else:
|
||||
files_to_load[loaded] = path
|
||||
loaded_translations: dict[str, Any] = {}
|
||||
translations_by_language[language] = loaded_translations
|
||||
|
||||
for loaded in components:
|
||||
domain = loaded.partition(".")[0]
|
||||
if not (integration := integrations.get(domain)):
|
||||
continue
|
||||
|
||||
path = component_translation_path(loaded, language, integration)
|
||||
# No translation available
|
||||
if path is None:
|
||||
loaded_translations[loaded] = {}
|
||||
else:
|
||||
files_to_load[loaded] = path
|
||||
|
||||
if not files_to_load:
|
||||
return translations
|
||||
return translations_by_language
|
||||
|
||||
# Load files
|
||||
load_translations_job = hass.async_add_executor_job(
|
||||
load_translations_files, files_to_load
|
||||
loaded_translations_by_language = await hass.async_add_executor_job(
|
||||
_load_translations_files_by_language, files_to_load_by_language
|
||||
)
|
||||
assert load_translations_job is not None
|
||||
loaded_translations = await load_translations_job
|
||||
|
||||
# Translations that miss "title" will get integration put in.
|
||||
for loaded, loaded_translation in loaded_translations.items():
|
||||
if "." in loaded:
|
||||
continue
|
||||
for language, loaded_translations in loaded_translations_by_language.items():
|
||||
for loaded, loaded_translation in loaded_translations.items():
|
||||
if "." in loaded:
|
||||
continue
|
||||
|
||||
if "title" not in loaded_translation:
|
||||
loaded_translation["title"] = integrations[loaded].name
|
||||
if "title" not in loaded_translation:
|
||||
loaded_translation["title"] = integrations[loaded].name
|
||||
|
||||
translations.update(loaded_translations)
|
||||
translations_by_language[language].update(loaded_translations)
|
||||
|
||||
return translations
|
||||
return translations_by_language
|
||||
|
||||
|
||||
class _TranslationCache:
|
||||
|
@ -280,13 +290,29 @@ class _TranslationCache:
|
|||
continue
|
||||
integrations[domain] = int_or_exc
|
||||
|
||||
for translation_strings in await asyncio.gather(
|
||||
*(
|
||||
_async_get_component_strings(self.hass, lang, components, integrations)
|
||||
for lang in languages
|
||||
translation_by_language_strings = await _async_get_component_strings(
|
||||
self.hass, languages, components, integrations
|
||||
)
|
||||
|
||||
# English is always the fallback language so we load them first
|
||||
self._build_category_cache(
|
||||
language, components, translation_by_language_strings[LOCALE_EN]
|
||||
)
|
||||
|
||||
if language != LOCALE_EN:
|
||||
# Now overlay the requested language on top of the English
|
||||
self._build_category_cache(
|
||||
language, components, translation_by_language_strings[language]
|
||||
)
|
||||
):
|
||||
self._build_category_cache(language, components, translation_strings)
|
||||
|
||||
loaded_english_components = self.loaded.setdefault(LOCALE_EN, set())
|
||||
# Since we just loaded english anyway we can avoid loading
|
||||
# again if they switch back to english.
|
||||
if loaded_english_components.isdisjoint(components):
|
||||
self._build_category_cache(
|
||||
LOCALE_EN, components, translation_by_language_strings[LOCALE_EN]
|
||||
)
|
||||
loaded_english_components.update(components)
|
||||
|
||||
self.loaded[language].update(components)
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import asyncio
|
||||
from os import path
|
||||
import pathlib
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
import pytest
|
||||
|
@ -79,7 +80,9 @@ async def test_component_translation_path(
|
|||
)
|
||||
|
||||
|
||||
def test_load_translations_files(hass: HomeAssistant) -> None:
|
||||
def test__load_translations_files_by_language(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test the load translation files function."""
|
||||
# Test one valid and one invalid file
|
||||
file1 = hass.config.path(
|
||||
|
@ -88,15 +91,22 @@ def test_load_translations_files(hass: HomeAssistant) -> None:
|
|||
file2 = hass.config.path(
|
||||
"custom_components", "test", "translations", "invalid.json"
|
||||
)
|
||||
assert translation.load_translations_files(
|
||||
{"switch.test": file1, "invalid": file2}
|
||||
file3 = hass.config.path(
|
||||
"custom_components", "test", "translations", "_broken.en.json"
|
||||
)
|
||||
assert translation._load_translations_files_by_language(
|
||||
{"en": {"switch.test": file1, "invalid": file2, "broken": file3}}
|
||||
) == {
|
||||
"switch.test": {
|
||||
"state": {"string1": "Value 1", "string2": "Value 2"},
|
||||
"something": "else",
|
||||
},
|
||||
"invalid": {},
|
||||
"en": {
|
||||
"switch.test": {
|
||||
"state": {"string1": "Value 1", "string2": "Value 2"},
|
||||
"something": "else",
|
||||
},
|
||||
"invalid": {},
|
||||
}
|
||||
}
|
||||
assert "Translation file is unexpected type" in caplog.text
|
||||
assert "_broken.en.json" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -215,8 +225,8 @@ async def test_get_translations_loads_config_flows(
|
|||
"homeassistant.helpers.translation.component_translation_path",
|
||||
return_value="bla.json",
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
return_value={"component1": {"title": "world"}},
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
return_value={"en": {"component1": {"title": "world"}}},
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.async_get_integrations",
|
||||
return_value={"component1": integration},
|
||||
|
@ -244,8 +254,8 @@ async def test_get_translations_loads_config_flows(
|
|||
"homeassistant.helpers.translation.component_translation_path",
|
||||
return_value="bla.json",
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
return_value={"component2": {"title": "world"}},
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
return_value={"en": {"component2": {"title": "world"}}},
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.async_get_integrations",
|
||||
return_value={"component2": integration},
|
||||
|
@ -280,19 +290,21 @@ async def test_get_translations_while_loading_components(hass: HomeAssistant) ->
|
|||
hass.config.components.add("component1")
|
||||
load_count = 0
|
||||
|
||||
def mock_load_translation_files(files):
|
||||
def mock_load_translation_files(
|
||||
files: dict[str, dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Mock load translation files."""
|
||||
nonlocal load_count
|
||||
load_count += 1
|
||||
# Mimic race condition by loading a component during setup
|
||||
|
||||
return {"component1": {"title": "world"}}
|
||||
return {language: {"component1": {"title": "world"}} for language in files}
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.component_translation_path",
|
||||
return_value="bla.json",
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
mock_load_translation_files,
|
||||
), patch(
|
||||
"homeassistant.helpers.translation.async_get_integrations",
|
||||
|
@ -330,18 +342,18 @@ async def test_translation_merging(
|
|||
hass.config.components.add("moon.sensor")
|
||||
hass.config.components.add("sensor")
|
||||
|
||||
orig_load_translations = translation.load_translations_files
|
||||
orig_load_translations = translation._load_translations_files_by_language
|
||||
|
||||
def mock_load_translations_files(files):
|
||||
"""Mock loading."""
|
||||
result = orig_load_translations(files)
|
||||
result["moon.sensor"] = {
|
||||
result["en"]["moon.sensor"] = {
|
||||
"state": {"moon__phase": {"first_quarter": "First Quarter"}}
|
||||
}
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
side_effect=mock_load_translations_files,
|
||||
):
|
||||
translations = await translation.async_get_translations(hass, "en", "state")
|
||||
|
@ -354,11 +366,11 @@ async def test_translation_merging(
|
|||
def mock_load_bad_translations_files(files):
|
||||
"""Mock loading."""
|
||||
result = orig_load_translations(files)
|
||||
result["season.sensor"] = {"state": "bad data"}
|
||||
result["en"]["season.sensor"] = {"state": "bad data"}
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
side_effect=mock_load_bad_translations_files,
|
||||
):
|
||||
translations = await translation.async_get_translations(hass, "en", "state")
|
||||
|
@ -375,12 +387,12 @@ async def test_translation_merging_loaded_apart(
|
|||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test we merge translations of two integrations when they are not loaded at the same time."""
|
||||
orig_load_translations = translation.load_translations_files
|
||||
orig_load_translations = translation._load_translations_files_by_language
|
||||
|
||||
def mock_load_translations_files(files):
|
||||
"""Mock loading."""
|
||||
result = orig_load_translations(files)
|
||||
result["moon.sensor"] = {
|
||||
result["en"]["moon.sensor"] = {
|
||||
"state": {"moon__phase": {"first_quarter": "First Quarter"}}
|
||||
}
|
||||
return result
|
||||
|
@ -388,7 +400,7 @@ async def test_translation_merging_loaded_apart(
|
|||
hass.config.components.add("sensor")
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
side_effect=mock_load_translations_files,
|
||||
):
|
||||
translations = await translation.async_get_translations(hass, "en", "state")
|
||||
|
@ -398,7 +410,7 @@ async def test_translation_merging_loaded_apart(
|
|||
hass.config.components.add("moon.sensor")
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
side_effect=mock_load_translations_files,
|
||||
):
|
||||
translations = await translation.async_get_translations(hass, "en", "state")
|
||||
|
@ -406,7 +418,7 @@ async def test_translation_merging_loaded_apart(
|
|||
assert "component.sensor.state.moon__phase.first_quarter" in translations
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.translation.load_translations_files",
|
||||
"homeassistant.helpers.translation._load_translations_files_by_language",
|
||||
side_effect=mock_load_translations_files,
|
||||
):
|
||||
translations = await translation.async_get_translations(
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
[]
|
Loading…
Reference in New Issue