Allow fetching translations by categories (#34329)

This commit is contained in:
Paulus Schoutsen 2020-04-18 17:13:13 -07:00 committed by GitHub
parent d1b3ed717e
commit 98f1548f2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 140 additions and 51 deletions

View File

@ -536,7 +536,13 @@ def websocket_get_themes(hass, connection, msg):
@websocket_api.websocket_command( @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 @websocket_api.async_response
async def websocket_get_translations(hass, connection, msg): async def websocket_get_translations(hass, connection, msg):
@ -544,7 +550,13 @@ async def websocket_get_translations(hass, connection, msg):
Async friendly. 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( connection.send_message(
websocket_api.result_message(msg["id"], {"resources": resources}) websocket_api.result_message(msg["id"], {"resources": resources})
) )

View File

@ -117,7 +117,7 @@ class UserOnboardingView(_BaseOnboardingView):
# Create default areas using the users supplied language. # Create default areas using the users supplied language.
translations = await hass.helpers.translation.async_get_translations( translations = await hass.helpers.translation.async_get_translations(
data["language"] data["language"], integration=DOMAIN
) )
area_registry = await hass.helpers.area_registry.async_get_registry() area_registry = await hass.helpers.area_registry.async_get_registry()

View File

@ -1,7 +1,7 @@
"""Translation string lookup helpers.""" """Translation string lookup helpers."""
import asyncio import asyncio
import logging import logging
from typing import Any, Dict, Iterable, Optional from typing import Any, Dict, Optional, Set
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.loader import ( from homeassistant.loader import (
@ -16,6 +16,7 @@ from .typing import HomeAssistantType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
TRANSLATION_LOAD_LOCK = "translation_load_lock"
TRANSLATION_STRING_CACHE = "translation_string_cache" TRANSLATION_STRING_CACHE = "translation_string_cache"
@ -36,7 +37,7 @@ def flatten(data: Dict) -> Dict[str, Any]:
@callback @callback
def component_translation_file( def component_translation_path(
component: str, language: str, integration: Integration component: str, language: str, integration: Integration
) -> Optional[str]: ) -> Optional[str]:
"""Return the translation json file location for a component. """Return the translation json file location for a component.
@ -80,7 +81,9 @@ def load_translations_files(
def build_resources( 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]]: ) -> Dict[str, Dict[str, Any]]:
"""Build the resources response for the given components.""" """Build the resources response for the given components."""
# Build response # Build response
@ -91,40 +94,43 @@ def build_resources(
else: else:
domain = component.split(".", 1)[0] domain = component.split(".", 1)[0]
if domain not in resources: domain_resources = resources.setdefault(domain, {})
resources[domain] = {}
# Add the translations for this component to the domain resources. # Add the translations for this component to the domain resources.
# Since clients cannot determine which platform an entity belongs to, # Since clients cannot determine which platform an entity belongs to,
# all translations for a domain will be returned together. # 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_cache(
async def async_get_component_resources( hass: HomeAssistantType, language: str, components: Set[str]
hass: HomeAssistantType, language: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Return translation resources for all components. """Return translation cache that includes all specified 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
"""
# Get cache for this language # Get cache for this language
cache = hass.data.setdefault(TRANSLATION_STRING_CACHE, {}) cache: Dict[str, Dict[str, Any]] = hass.data.setdefault(
translation_cache = cache.setdefault(language, {}) TRANSLATION_STRING_CACHE, {}
)
# Get the set of components to check translation_cache: Dict[str, Any] = cache.setdefault(language, {})
components = hass.config.components | await async_get_config_flows(hass)
# Calculate the missing components and platforms # Calculate the missing components and platforms
missing_loaded = components - set(translation_cache) 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( missing_integrations = dict(
zip( zip(
missing_domains, missing_domains,
@ -141,7 +147,7 @@ async def async_get_component_resources(
domain = parts[-1] domain = parts[-1]
integration = missing_integrations[domain] integration = missing_integrations[domain]
path = component_translation_file(loaded, language, integration) path = component_translation_path(loaded, language, integration)
# No translation available # No translation available
if path is None: if path is None:
translation_cache[loaded] = {} translation_cache[loaded] = {}
@ -167,22 +173,47 @@ async def async_get_component_resources(
# Update cache # Update cache
translation_cache.update(loaded_translations) translation_cache.update(loaded_translations)
resources = build_resources(translation_cache, components) return translation_cache
# Return the component translations resources under the 'component'
# translation namespace
return flatten({"component": resources})
@bind_hass @bind_hass
async def async_get_translations( 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]: ) -> Dict[str, Any]:
"""Return all backend translations.""" """Return all backend translations.
resources = await async_get_component_resources(hass, language)
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": if language != "en":
# Fetch the English resources, as a fallback for missing keys tasks.append(async_get_component_cache(hass, "en", components))
base_resources = await async_get_component_resources(hass, "en")
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} resources = {**base_resources, **resources}
return resources return resources

View File

@ -16,7 +16,7 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import HTTP_NOT_FOUND from homeassistant.const import HTTP_NOT_FOUND
from homeassistant.setup import async_setup_component 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"}}}} CONFIG_THEMES = {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}}
@ -283,10 +283,17 @@ async def test_get_translations(hass, hass_ws_client):
with patch( with patch(
"homeassistant.components.frontend.async_get_translations", "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( 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() msg = await client.receive_json()

View File

@ -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.""" """Test the component translation file function."""
assert await async_setup_component( assert await async_setup_component(
hass, hass,
@ -58,13 +58,13 @@ async def test_component_translation_file(hass):
) )
assert path.normpath( assert path.normpath(
translation.component_translation_file("switch.test", "en", int_test) translation.component_translation_path("switch.test", "en", int_test)
) == path.normpath( ) == path.normpath(
hass.config.path("custom_components", "test", ".translations", "switch.en.json") hass.config.path("custom_components", "test", ".translations", "switch.en.json")
) )
assert path.normpath( assert path.normpath(
translation.component_translation_file( translation.component_translation_path(
"switch.test_embedded", "en", int_test_embedded "switch.test_embedded", "en", int_test_embedded
) )
) == path.normpath( ) == path.normpath(
@ -74,14 +74,14 @@ async def test_component_translation_file(hass):
) )
assert ( assert (
translation.component_translation_file( translation.component_translation_path(
"test_standalone", "en", int_test_standalone "test_standalone", "en", int_test_standalone
) )
is None is None
) )
assert path.normpath( 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( ) == path.normpath(
hass.config.path( hass.config.path(
"custom_components", "test_package", ".translations", "en.json" "custom_components", "test_package", ".translations", "en.json"
@ -101,7 +101,10 @@ def test_load_translations_files(hass):
assert translation.load_translations_files( assert translation.load_translations_files(
{"switch.test": file1, "invalid": file2} {"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": {}, "invalid": {},
} }
@ -115,10 +118,12 @@ async def test_get_translations(hass, mock_config_flows):
translations = await translation.async_get_translations(hass, "en") 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.string1"] == "Value 1"
assert translations["component.switch.state.string2"] == "Value 2" 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.string1"] == "German Value 1"
assert translations["component.switch.state.string2"] == "German Value 2" 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" integration.name = "Component 1"
with patch.object( 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( ), patch.object(
translation, translation,
"load_translations_files", "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", "homeassistant.helpers.translation.async_get_integration",
return_value=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 == { assert translations == {
"component.component1.title": "Component 1", "component.component1.title": "Component 1",
"component.component1.hello": "world", "component.component1.hello": "world",

View File

@ -2,5 +2,6 @@
"state": { "state": {
"string1": "Value 1", "string1": "Value 1",
"string2": "Value 2" "string2": "Value 2"
} },
"something": "else"
} }