Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions homeassistant/components/frontend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,15 +536,27 @@ 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):
"""Handle get translations command.

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})
)
2 changes: 1 addition & 1 deletion homeassistant/components/onboarding/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ async def post(self, request, data):

# 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()
Expand Down
101 changes: 66 additions & 35 deletions homeassistant/helpers/translation.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -16,6 +16,7 @@

_LOGGER = logging.getLogger(__name__)

TRANSLATION_LOAD_LOCK = "translation_load_lock"
TRANSLATION_STRING_CACHE = "translation_string_cache"


Expand All @@ -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.
Expand Down Expand Up @@ -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],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the other categories than the languages?

) -> Dict[str, Dict[str, Any]]:
"""Build the resources response for the given components."""
# Build response
Expand All @@ -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])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the translation cache contain keys that are either languages or domains?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

categories are the top level entries in strings.json. config, options, etc. A list is here

So the dict is

{ "light": { "device_automation": {…} }

continue

if category not in translation_cache[component]:
continue

@bind_hass
async def async_get_component_resources(
hass: HomeAssistantType, language: str
) -> Dict[str, Any]:
"""Return translation resources for all components.
domain_resources.setdefault(category, {}).update(
translation_cache[component][category]
)

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
cache = hass.data.setdefault(TRANSLATION_STRING_CACHE, {})
translation_cache = cache.setdefault(language, {})
return {"component": resources}

# Get the set of components to check
components = hass.config.components | await async_get_config_flows(hass)

async def async_get_component_cache(
hass: HomeAssistantType, language: str, components: Set[str]
) -> Dict[str, Any]:
"""Return translation cache that includes all specified components."""
# Get cache for this language
cache: Dict[str, Dict[str, Any]] = hass.data.setdefault(
TRANSLATION_STRING_CACHE, {}
)
translation_cache: Dict[str, Any] = cache.setdefault(language, {})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we seem to set a language as top level key in the translation cache. How does that match with your description in the comment?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I got confused with all the nested dicts. The translation cache here and what we return is one level below the language key.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We return translation_cache, which has the described structure.

You're right that cache is { language: translation_cache }.


# 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,
Expand All @@ -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] = {}
Expand All @@ -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":
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":
# Fetch the English resources, as a fallback for missing keys
base_resources = await async_get_component_resources(hass, "en")
base_resources = flatten(build_resources(results[1], components, category))
resources = {**base_resources, **resources}

return resources
13 changes: 10 additions & 3 deletions tests/components/frontend/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
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"}}}}

Expand Down Expand Up @@ -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()

Expand Down
54 changes: 46 additions & 8 deletions tests/helpers/test_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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"
Expand All @@ -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": {},
}

Expand All @@ -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"

Expand All @@ -140,16 +145,49 @@ 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",
return_value={"component1": {"hello": "world"}},
), patch(
"homeassistant.helpers.translation.async_get_integration",
return_value=integration,
):
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"state": {
"string1": "Value 1",
"string2": "Value 2"
}
},
"something": "else"
}