From 98f1548f2d4a1ef7051ec7611fc9aa2be7d96102 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 18 Apr 2020 17:13:13 -0700 Subject: [PATCH] Allow fetching translations by categories (#34329) --- homeassistant/components/frontend/__init__.py | 16 ++- homeassistant/components/onboarding/views.py | 2 +- homeassistant/helpers/translation.py | 101 ++++++++++++------ tests/components/frontend/test_init.py | 13 ++- tests/helpers/test_translation.py | 56 ++++++++-- .../test/.translations/switch.en.json | 3 +- 6 files changed, 140 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2ce710ea419..f6a16205755 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -536,7 +536,13 @@ def websocket_get_themes(hass, connection, msg): @websocket_api.websocket_command( - {"type": "frontend/get_translations", vol.Required("language"): str} + { + "type": "frontend/get_translations", + vol.Required("language"): str, + vol.Required("category"): str, + vol.Optional("integration"): str, + vol.Optional("config_flow"): bool, + } ) @websocket_api.async_response async def websocket_get_translations(hass, connection, msg): @@ -544,7 +550,13 @@ async def websocket_get_translations(hass, connection, msg): Async friendly. """ - resources = await async_get_translations(hass, msg["language"]) + resources = await async_get_translations( + hass, + msg["language"], + msg["category"], + msg.get("integration"), + msg.get("config_flow"), + ) connection.send_message( websocket_api.result_message(msg["id"], {"resources": resources}) ) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 32a71ce14e4..e36aecf9daf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -117,7 +117,7 @@ class UserOnboardingView(_BaseOnboardingView): # Create default areas using the users supplied language. translations = await hass.helpers.translation.async_get_translations( - data["language"] + data["language"], integration=DOMAIN ) area_registry = await hass.helpers.area_registry.async_get_registry() diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 6989e7770b9..888f2838372 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -1,7 +1,7 @@ """Translation string lookup helpers.""" import asyncio import logging -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Optional, Set from homeassistant.core import callback from homeassistant.loader import ( @@ -16,6 +16,7 @@ from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) +TRANSLATION_LOAD_LOCK = "translation_load_lock" TRANSLATION_STRING_CACHE = "translation_string_cache" @@ -36,7 +37,7 @@ def flatten(data: Dict) -> Dict[str, Any]: @callback -def component_translation_file( +def component_translation_path( component: str, language: str, integration: Integration ) -> Optional[str]: """Return the translation json file location for a component. @@ -80,7 +81,9 @@ def load_translations_files( def build_resources( - translation_cache: Dict[str, Dict[str, Any]], components: Iterable[str] + translation_cache: Dict[str, Dict[str, Any]], + components: Set[str], + category: Optional[str], ) -> Dict[str, Dict[str, Any]]: """Build the resources response for the given components.""" # Build response @@ -91,40 +94,43 @@ def build_resources( else: domain = component.split(".", 1)[0] - if domain not in resources: - resources[domain] = {} + domain_resources = resources.setdefault(domain, {}) # Add the translations for this component to the domain resources. # Since clients cannot determine which platform an entity belongs to, # all translations for a domain will be returned together. - resources[domain].update(translation_cache[component]) - return resources + if category is None: + domain_resources.update(translation_cache[component]) + continue + + if category not in translation_cache[component]: + continue + + domain_resources.setdefault(category, {}).update( + translation_cache[component][category] + ) + + return {"component": resources} -@bind_hass -async def async_get_component_resources( - hass: HomeAssistantType, language: str +async def async_get_component_cache( + hass: HomeAssistantType, language: str, components: Set[str] ) -> Dict[str, Any]: - """Return translation resources for all components. - - We go through all loaded components and platforms: - - see if they have already been loaded (exist in translation_cache) - - load them if they have not been loaded yet - - write them to cache - - flatten the cache and return - """ + """Return translation cache that includes all specified components.""" # Get cache for this language - cache = hass.data.setdefault(TRANSLATION_STRING_CACHE, {}) - translation_cache = cache.setdefault(language, {}) - - # Get the set of components to check - components = hass.config.components | await async_get_config_flows(hass) + cache: Dict[str, Dict[str, Any]] = hass.data.setdefault( + TRANSLATION_STRING_CACHE, {} + ) + translation_cache: Dict[str, Any] = cache.setdefault(language, {}) # Calculate the missing components and platforms missing_loaded = components - set(translation_cache) - missing_domains = {loaded.split(".")[-1] for loaded in missing_loaded} + if not missing_loaded: + return translation_cache + + missing_domains = list({loaded.split(".")[-1] for loaded in missing_loaded}) missing_integrations = dict( zip( missing_domains, @@ -141,7 +147,7 @@ async def async_get_component_resources( domain = parts[-1] integration = missing_integrations[domain] - path = component_translation_file(loaded, language, integration) + path = component_translation_path(loaded, language, integration) # No translation available if path is None: translation_cache[loaded] = {} @@ -167,22 +173,47 @@ async def async_get_component_resources( # Update cache translation_cache.update(loaded_translations) - resources = build_resources(translation_cache, components) - - # Return the component translations resources under the 'component' - # translation namespace - return flatten({"component": resources}) + return translation_cache @bind_hass async def async_get_translations( - hass: HomeAssistantType, language: str + hass: HomeAssistantType, + language: str, + category: Optional[str] = None, + integration: Optional[str] = None, + config_flow: Optional[bool] = None, ) -> Dict[str, Any]: - """Return all backend translations.""" - resources = await async_get_component_resources(hass, language) + """Return all backend translations. + + If integration specified, load it for that one. + Otherwise default to loaded intgrations combined with config flow + integrations if config_flow is true. + """ + if integration is not None: + components = {integration} + elif config_flow: + components = hass.config.components | await async_get_config_flows(hass) + else: + components = set(hass.config.components) + + lock = hass.data.get(TRANSLATION_LOAD_LOCK) + if lock is None: + lock = hass.data[TRANSLATION_LOAD_LOCK] = asyncio.Lock() + + tasks = [async_get_component_cache(hass, language, components)] + + # Fetch the English resources, as a fallback for missing keys if language != "en": - # Fetch the English resources, as a fallback for missing keys - base_resources = await async_get_component_resources(hass, "en") + tasks.append(async_get_component_cache(hass, "en", components)) + + async with lock: + results = await asyncio.gather(*tasks) + + resources = flatten(build_resources(results[0], components, category)) + + if language != "en": + base_resources = flatten(build_resources(results[1], components, category)) resources = {**base_resources, **resources} return resources diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 2d07fb33314..7297812249c 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component -from tests.common import async_capture_events, mock_coro +from tests.common import async_capture_events CONFIG_THEMES = {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}} @@ -283,10 +283,17 @@ async def test_get_translations(hass, hass_ws_client): with patch( "homeassistant.components.frontend.async_get_translations", - side_effect=lambda hass, lang: mock_coro({"lang": lang}), + side_effect=lambda hass, lang, category, integration, config_flow: { + "lang": lang + }, ): await client.send_json( - {"id": 5, "type": "frontend/get_translations", "language": "nl"} + { + "id": 5, + "type": "frontend/get_translations", + "language": "nl", + "category": "lang", + } ) msg = await client.receive_json() diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index f85425c575f..d0ec7e86b0c 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -35,7 +35,7 @@ def test_flatten(): } -async def test_component_translation_file(hass): +async def test_component_translation_path(hass): """Test the component translation file function.""" assert await async_setup_component( hass, @@ -58,13 +58,13 @@ async def test_component_translation_file(hass): ) assert path.normpath( - translation.component_translation_file("switch.test", "en", int_test) + translation.component_translation_path("switch.test", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", ".translations", "switch.en.json") ) assert path.normpath( - translation.component_translation_file( + translation.component_translation_path( "switch.test_embedded", "en", int_test_embedded ) ) == path.normpath( @@ -74,14 +74,14 @@ async def test_component_translation_file(hass): ) assert ( - translation.component_translation_file( + translation.component_translation_path( "test_standalone", "en", int_test_standalone ) is None ) assert path.normpath( - translation.component_translation_file("test_package", "en", int_test_package) + translation.component_translation_path("test_package", "en", int_test_package) ) == path.normpath( hass.config.path( "custom_components", "test_package", ".translations", "en.json" @@ -101,7 +101,10 @@ def test_load_translations_files(hass): assert translation.load_translations_files( {"switch.test": file1, "invalid": file2} ) == { - "switch.test": {"state": {"string1": "Value 1", "string2": "Value 2"}}, + "switch.test": { + "state": {"string1": "Value 1", "string2": "Value 2"}, + "something": "else", + }, "invalid": {}, } @@ -115,10 +118,12 @@ async def test_get_translations(hass, mock_config_flows): translations = await translation.async_get_translations(hass, "en") + assert translations["component.switch.something"] == "else" assert translations["component.switch.state.string1"] == "Value 1" assert translations["component.switch.state.string2"] == "Value 2" - translations = await translation.async_get_translations(hass, "de") + translations = await translation.async_get_translations(hass, "de", "state") + assert "component.switch.something" not in translations assert translations["component.switch.state.string1"] == "German Value 1" assert translations["component.switch.state.string2"] == "German Value 2" @@ -140,7 +145,7 @@ async def test_get_translations_loads_config_flows(hass, mock_config_flows): integration.name = "Component 1" with patch.object( - translation, "component_translation_file", return_value=mock_coro("bla.json") + translation, "component_translation_path", return_value=mock_coro("bla.json") ), patch.object( translation, "load_translations_files", @@ -149,7 +154,40 @@ async def test_get_translations_loads_config_flows(hass, mock_config_flows): "homeassistant.helpers.translation.async_get_integration", return_value=integration, ): - translations = await translation.async_get_translations(hass, "en") + translations = await translation.async_get_translations( + hass, "en", config_flow=True + ) + + assert translations == { + "component.component1.title": "Component 1", + "component.component1.hello": "world", + } + + assert "component1" not in hass.config.components + + +async def test_get_translations_while_loading_components(hass): + """Test the get translations helper loads config flow translations.""" + integration = Mock(file_path=pathlib.Path(__file__)) + integration.name = "Component 1" + hass.config.components.add("component1") + + async def mock_load_translation_files(files): + """Mock load translation files.""" + # Mimic race condition by loading a component during setup + await async_setup_component(hass, "persistent_notification", {}) + return {"component1": {"hello": "world"}} + + with patch.object( + translation, "component_translation_path", return_value=mock_coro("bla.json") + ), patch.object( + translation, "load_translations_files", side_effect=mock_load_translation_files, + ), patch( + "homeassistant.helpers.translation.async_get_integration", + return_value=integration, + ): + translations = await translation.async_get_translations(hass, "en") + assert translations == { "component.component1.title": "Component 1", "component.component1.hello": "world", diff --git a/tests/testing_config/custom_components/test/.translations/switch.en.json b/tests/testing_config/custom_components/test/.translations/switch.en.json index f4ce728af05..1cc764adb21 100644 --- a/tests/testing_config/custom_components/test/.translations/switch.en.json +++ b/tests/testing_config/custom_components/test/.translations/switch.en.json @@ -2,5 +2,6 @@ "state": { "string1": "Value 1", "string2": "Value 2" - } + }, + "something": "else" }