Make all translations cacheable (#42892)

This commit is contained in:
J. Nick Koston 2020-11-09 11:36:45 -10:00 committed by GitHub
parent 3187c7cc9d
commit c7f35b20fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 224 additions and 155 deletions

View File

@ -1,10 +1,10 @@
"""Translation string lookup helpers.""" """Translation string lookup helpers."""
import asyncio import asyncio
from collections import ChainMap
import logging import logging
from typing import Any, Dict, Optional, Set from typing import Any, Dict, List, Optional, Set
from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import callback
from homeassistant.core import Event, callback
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
async_get_config_flows, async_get_config_flows,
@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__)
TRANSLATION_LOAD_LOCK = "translation_load_lock" TRANSLATION_LOAD_LOCK = "translation_load_lock"
TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache"
LOCALE_EN = "en"
def recursive_flatten(prefix: Any, data: Dict) -> Dict[str, Any]: def recursive_flatten(prefix: Any, data: Dict) -> Dict[str, Any]:
@ -32,11 +33,6 @@ def recursive_flatten(prefix: Any, data: Dict) -> Dict[str, Any]:
return output return output
def flatten(data: Dict) -> Dict[str, Any]:
"""Return a flattened representation of dict data."""
return recursive_flatten("", data)
@callback @callback
def component_translation_path( def component_translation_path(
component: str, language: str, integration: Integration component: str, language: str, integration: Integration
@ -91,7 +87,7 @@ def load_translations_files(
return loaded return loaded
def merge_resources( def _merge_resources(
translation_strings: Dict[str, Dict[str, Any]], translation_strings: Dict[str, Dict[str, Any]],
components: Set[str], components: Set[str],
category: str, category: str,
@ -120,57 +116,31 @@ def merge_resources(
if new_value is None: if new_value is None:
continue continue
cur_value = domain_resources.get(category) if isinstance(new_value, dict):
domain_resources.update(new_value)
# If not exists, set value.
if cur_value is None:
domain_resources[category] = new_value
# If exists, and a list, append
elif isinstance(cur_value, list):
cur_value.append(new_value)
# If exists, and a dict make it a list with 2 entries.
else:
domain_resources[category] = [cur_value, new_value]
# Merge all the lists
for domain, domain_resources in list(resources.items()):
if not isinstance(domain_resources.get(category), list):
continue
merged = {}
for entry in domain_resources[category]:
if isinstance(entry, dict):
merged.update(entry)
else: else:
_LOGGER.error( _LOGGER.error(
"An integration providing translations for %s provided invalid data: %s", "An integration providing translations for %s provided invalid data: %s",
domain, domain,
entry, new_value,
) )
domain_resources[category] = merged
return {"component": resources} return resources
def build_resources( def _build_resources(
translation_strings: Dict[str, Dict[str, Any]], translation_strings: Dict[str, Dict[str, Any]],
components: Set[str], components: Set[str],
category: str, category: 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
resources: Dict[str, Dict[str, Any]] = {} return {
for component in components: component: translation_strings[component][category]
new_value = translation_strings[component].get(category) for component in components
if category in translation_strings[component]
if new_value is None: and translation_strings[component][category] is not None
continue }
resources[component] = {category: new_value}
return {"component": resources}
async def async_get_component_strings( async def async_get_component_strings(
@ -226,35 +196,83 @@ async def async_get_component_strings(
return translations return translations
class FlatCache: class _TranslationCache:
"""Cache for flattened translations.""" """Cache for flattened translations."""
def __init__(self, hass: HomeAssistantType) -> None: def __init__(self, hass: HomeAssistantType) -> None:
"""Initialize the cache.""" """Initialize the cache."""
self.hass = hass self.hass = hass
self.cache: Dict[str, Dict[str, Dict[str, str]]] = {} self.loaded: Dict[str, Set[str]] = {}
self.cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
async def async_fetch(
self,
language: str,
category: str,
components: Set,
) -> List[Dict[str, Dict[str, Any]]]:
"""Load resources into the cache."""
components_to_load = components - self.loaded.setdefault(language, set())
if components_to_load:
await self._async_load(language, components_to_load)
cached = self.cache.get(language, {})
return [cached.get(component, {}).get(category, {}) for component in components]
async def _async_load(self, language: str, components: Set) -> None:
"""Populate the cache for a given set of components."""
_LOGGER.debug(
"Cache miss for %s: %s",
language,
", ".join(components),
)
# Fetch the English resources, as a fallback for missing keys
languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language]
for translation_strings in await asyncio.gather(
*[
async_get_component_strings(self.hass, lang, components)
for lang in languages
]
):
self._build_category_cache(language, components, translation_strings)
self.loaded[language].update(components)
@callback @callback
def async_setup(self) -> None: def _build_category_cache(
"""Initialize the cache clear listeners.""" self,
self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, self._async_component_loaded) language: str,
components: Set,
@callback translation_strings: Dict[str, Dict[str, Any]],
def _async_component_loaded(self, event: Event) -> None:
"""Clear cache when a new component is loaded."""
self.cache = {}
@callback
def async_get_cache(self, language: str, category: str) -> Optional[Dict[str, str]]:
"""Get cache."""
return self.cache.setdefault(language, {}).get(category)
@callback
def async_set_cache(
self, language: str, category: str, data: Dict[str, str]
) -> None: ) -> None:
"""Set cache.""" """Extract resources into the cache."""
self.cache.setdefault(language, {})[category] = data cached = self.cache.setdefault(language, {})
categories: Set[str] = set()
for resource in translation_strings.values():
categories.update(resource)
for category in categories:
resource_func = (
_merge_resources if category == "state" else _build_resources
)
new_resources = resource_func(translation_strings, components, category)
for component, resource in new_resources.items():
category_cache: Dict[str, Any] = cached.setdefault(
component, {}
).setdefault(category, {})
if isinstance(resource, dict):
category_cache.update(
recursive_flatten(
f"component.{component}.{category}.",
resource,
)
)
else:
category_cache[f"component.{component}.{category}"] = resource
@bind_hass @bind_hass
@ -271,71 +289,22 @@ async def async_get_translations(
Otherwise default to loaded intgrations combined with config flow Otherwise default to loaded intgrations combined with config flow
integrations if config_flow is true. integrations if config_flow is true.
""" """
lock = hass.data.get(TRANSLATION_LOAD_LOCK) lock = hass.data.setdefault(TRANSLATION_LOAD_LOCK, asyncio.Lock())
if lock is None:
lock = hass.data[TRANSLATION_LOAD_LOCK] = asyncio.Lock()
if integration is not None: if integration is not None:
components = {integration} components = {integration}
elif config_flow: elif config_flow:
# When it's a config flow, we're going to merge the cached loaded component results
# with the integrations that have not been loaded yet. We merge this at the end.
# We can't cache with config flow, as we can't monitor it during runtime.
components = (await async_get_config_flows(hass)) - hass.config.components components = (await async_get_config_flows(hass)) - hass.config.components
else: elif category == "state":
# Only 'state' supports merging, so remove platforms from selection
if category == "state":
components = set(hass.config.components) components = set(hass.config.components)
else: else:
# Only 'state' supports merging, so remove platforms from selection
components = { components = {
component component for component in hass.config.components if "." not in component
for component in hass.config.components
if "." not in component
} }
async with lock: async with lock:
use_cache = integration is None and not config_flow cache = hass.data.setdefault(TRANSLATION_FLATTEN_CACHE, _TranslationCache(hass))
if use_cache: cached = await cache.async_fetch(language, category, components)
cache = hass.data.get(TRANSLATION_FLATTEN_CACHE)
if cache is None:
cache = hass.data[TRANSLATION_FLATTEN_CACHE] = FlatCache(hass)
cache.async_setup()
cached_translations = cache.async_get_cache(language, category) return dict(ChainMap(*cached))
if cached_translations is not None:
return cached_translations
tasks = [async_get_component_strings(hass, language, components)]
# Fetch the English resources, as a fallback for missing keys
if language != "en":
tasks.append(async_get_component_strings(hass, "en", components))
_LOGGER.debug(
"Cache miss for %s, %s: %s", language, category, ", ".join(components)
)
results = await asyncio.gather(*tasks)
if category == "state":
resource_func = merge_resources
else:
resource_func = build_resources
resources = flatten(resource_func(results[0], components, category))
if language != "en":
base_resources = flatten(resource_func(results[1], components, category))
resources = {**base_resources, **resources}
# The cache must be set while holding the lock
if use_cache:
assert cache is not None
cache.async_set_cache(language, category, resources)
if config_flow:
loaded_comp_resources = await async_get_translations(hass, language, category)
resources.update(loaded_comp_resources)
return resources

View File

@ -5,7 +5,6 @@ import pathlib
import pytest import pytest
from homeassistant.const import EVENT_COMPONENT_LOADED
from homeassistant.generated import config_flows from homeassistant.generated import config_flows
from homeassistant.helpers import translation from homeassistant.helpers import translation
from homeassistant.loader import async_get_integration from homeassistant.loader import async_get_integration
@ -22,16 +21,16 @@ def mock_config_flows():
yield flows yield flows
def test_flatten(): def test_recursive_flatten():
"""Test the flatten function.""" """Test the flatten function."""
data = {"parent1": {"child1": "data1", "child2": "data2"}, "parent2": "data3"} data = {"parent1": {"child1": "data1", "child2": "data2"}, "parent2": "data3"}
flattened = translation.flatten(data) flattened = translation.recursive_flatten("prefix.", data)
assert flattened == { assert flattened == {
"parent1.child1": "data1", "prefix.parent1.child1": "data1",
"parent1.child2": "data2", "prefix.parent1.child2": "data2",
"parent2": "data3", "prefix.parent2": "data3",
} }
@ -149,21 +148,62 @@ async def test_get_translations_loads_config_flows(hass, mock_config_flows):
return_value="bla.json", return_value="bla.json",
), patch( ), patch(
"homeassistant.helpers.translation.load_translations_files", "homeassistant.helpers.translation.load_translations_files",
return_value={"component1": {"hello": "world"}}, return_value={"component1": {"title": "world"}},
), patch( ), patch(
"homeassistant.helpers.translation.async_get_integration", "homeassistant.helpers.translation.async_get_integration",
return_value=integration, return_value=integration,
): ):
translations = await translation.async_get_translations( translations = await translation.async_get_translations(
hass, "en", "hello", config_flow=True hass, "en", "title", config_flow=True
)
translations_again = await translation.async_get_translations(
hass, "en", "title", config_flow=True
) )
assert translations == translations_again
assert translations == { assert translations == {
"component.component1.hello": "world", "component.component1.title": "world",
} }
assert "component1" not in hass.config.components assert "component1" not in hass.config.components
mock_config_flows.append("component2")
integration = Mock(file_path=pathlib.Path(__file__))
integration.name = "Component 2"
with patch(
"homeassistant.helpers.translation.component_translation_path",
return_value="bla.json",
), patch(
"homeassistant.helpers.translation.load_translations_files",
return_value={"component2": {"title": "world"}},
), patch(
"homeassistant.helpers.translation.async_get_integration",
return_value=integration,
):
translations = await translation.async_get_translations(
hass, "en", "title", config_flow=True
)
translations_again = await translation.async_get_translations(
hass, "en", "title", config_flow=True
)
assert translations == translations_again
assert translations == {
"component.component1.title": "world",
"component.component2.title": "world",
}
translations_all_cached = await translation.async_get_translations(
hass, "en", "title", config_flow=True
)
assert translations == translations_all_cached
assert "component1" not in hass.config.components
assert "component2" not in hass.config.components
async def test_get_translations_while_loading_components(hass): async def test_get_translations_while_loading_components(hass):
"""Test the get translations helper loads config flow translations.""" """Test the get translations helper loads config flow translations."""
@ -178,7 +218,7 @@ async def test_get_translations_while_loading_components(hass):
load_count += 1 load_count += 1
# Mimic race condition by loading a component during setup # Mimic race condition by loading a component during setup
setup_component(hass, "persistent_notification", {}) setup_component(hass, "persistent_notification", {})
return {"component1": {"hello": "world"}} return {"component1": {"title": "world"}}
with patch( with patch(
"homeassistant.helpers.translation.component_translation_path", "homeassistant.helpers.translation.component_translation_path",
@ -191,12 +231,12 @@ async def test_get_translations_while_loading_components(hass):
return_value=integration, return_value=integration,
): ):
tasks = [ tasks = [
translation.async_get_translations(hass, "en", "hello") for _ in range(5) translation.async_get_translations(hass, "en", "title") for _ in range(5)
] ]
all_translations = await asyncio.gather(*tasks) all_translations = await asyncio.gather(*tasks)
assert all_translations[0] == { assert all_translations[0] == {
"component.component1.hello": "world", "component.component1.title": "world",
} }
assert load_count == 1 assert load_count == 1
@ -218,17 +258,13 @@ async def test_get_translation_categories(hass):
async def test_translation_merging(hass, caplog): async def test_translation_merging(hass, caplog):
"""Test we merge translations of two integrations.""" """Test we merge translations of two integrations."""
hass.config.components.add("sensor.moon") hass.config.components.add("sensor.moon")
hass.config.components.add("sensor.season")
hass.config.components.add("sensor") hass.config.components.add("sensor")
translations = await translation.async_get_translations(hass, "en", "state") translations = await translation.async_get_translations(hass, "en", "state")
assert "component.sensor.state.moon__phase.first_quarter" in translations assert "component.sensor.state.moon__phase.first_quarter" in translations
assert "component.sensor.state.season__season.summer" in translations
# Clear cache hass.config.components.add("sensor.season")
hass.bus.async_fire(EVENT_COMPONENT_LOADED)
await hass.async_block_till_done()
# Patch in some bad translation data # Patch in some bad translation data
@ -254,27 +290,91 @@ async def test_translation_merging(hass, caplog):
) )
async def test_translation_merging_loaded_apart(hass, caplog):
"""Test we merge translations of two integrations when they are not loaded at the same time."""
hass.config.components.add("sensor")
translations = await translation.async_get_translations(hass, "en", "state")
assert "component.sensor.state.moon__phase.first_quarter" not in translations
hass.config.components.add("sensor.moon")
translations = await translation.async_get_translations(hass, "en", "state")
assert "component.sensor.state.moon__phase.first_quarter" in translations
translations = await translation.async_get_translations(
hass, "en", "state", integration="sensor"
)
assert "component.sensor.state.moon__phase.first_quarter" in translations
async def test_caching(hass): async def test_caching(hass):
"""Test we cache data.""" """Test we cache data."""
hass.config.components.add("sensor") hass.config.components.add("sensor")
hass.config.components.add("light")
# Patch with same method so we can count invocations # Patch with same method so we can count invocations
with patch( with patch(
"homeassistant.helpers.translation.merge_resources", "homeassistant.helpers.translation._merge_resources",
side_effect=translation.merge_resources, side_effect=translation._merge_resources,
) as mock_merge: ) as mock_merge:
await translation.async_get_translations(hass, "en", "state") load1 = await translation.async_get_translations(hass, "en", "state")
assert len(mock_merge.mock_calls) == 1 assert len(mock_merge.mock_calls) == 1
await translation.async_get_translations(hass, "en", "state") load2 = await translation.async_get_translations(hass, "en", "state")
assert len(mock_merge.mock_calls) == 1 assert len(mock_merge.mock_calls) == 1
# This event clears the cache so we should record another call assert load1 == load2
hass.bus.async_fire(EVENT_COMPONENT_LOADED)
await hass.async_block_till_done()
await translation.async_get_translations(hass, "en", "state") for key in load1:
assert len(mock_merge.mock_calls) == 2 assert key.startswith("component.sensor.state.") or key.startswith(
"component.light.state."
)
load_sensor_only = await translation.async_get_translations(
hass, "en", "state", integration="sensor"
)
assert load_sensor_only
for key in load_sensor_only:
assert key.startswith("component.sensor.state.")
load_light_only = await translation.async_get_translations(
hass, "en", "state", integration="light"
)
assert load_light_only
for key in load_light_only:
assert key.startswith("component.light.state.")
hass.config.components.add("media_player")
# Patch with same method so we can count invocations
with patch(
"homeassistant.helpers.translation._build_resources",
side_effect=translation._build_resources,
) as mock_build:
load_sensor_only = await translation.async_get_translations(
hass, "en", "title", integration="sensor"
)
assert load_sensor_only
for key in load_sensor_only:
assert key == "component.sensor.title"
assert len(mock_build.mock_calls) == 0
assert await translation.async_get_translations(
hass, "en", "title", integration="sensor"
)
assert len(mock_build.mock_calls) == 0
load_light_only = await translation.async_get_translations(
hass, "en", "title", integration="media_player"
)
assert load_light_only
for key in load_light_only:
assert key == "component.media_player.title"
assert len(mock_build.mock_calls) > 1
async def test_custom_component_translations(hass): async def test_custom_component_translations(hass):