Add state_translated function to jinja templates (#96906)

* Add state_translated jinja function

* Add tests for load_state_translations_to_cache and get_cached_translations

* Cleanup state_translated template

* Add tests for state_translated jinja function

* Apply black formatting

* Improve code quality

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Apply suggestions from code review

* Prevent invalid components from loading translations

* Refactor loading translations to cache

* Adjust code issues

* Update homeassistant/helpers/translation.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Refactor listeners that trigger translation loading

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Apply suggestions from code review

* Adjust invalid function calls, fix code styling

* Adjust code quality

* Extract async_translate_state function

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Apply suggestions from code review

* Fix tests

* Fix tests

---------

Co-authored-by: Piotr Machowski <PiotrMachowski@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
pull/110179/head
Piotr Machowski 2024-02-10 10:47:56 +01:00 committed by GitHub
parent d1f098c11f
commit a2f4e99994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 555 additions and 15 deletions

View File

@ -35,6 +35,7 @@ from .helpers import (
recorder,
restore_state,
template,
translation,
)
from .helpers.dispatcher import async_dispatcher_send
from .helpers.typing import ConfigType
@ -291,6 +292,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
platform.uname().processor # pylint: disable=expression-not-assigned
# Load the registries and cache the result of platform.uname().processor
translation.async_setup(hass)
entity.async_setup(hass)
template.async_setup(hass)
await asyncio.gather(

View File

@ -80,6 +80,7 @@ from homeassistant.util.thread import ThreadWithException
from . import area_registry, device_registry, entity_registry, location as loc_helper
from .singleton import singleton
from .translation import async_translate_state
from .typing import TemplateVarsType
# mypy: allow-untyped-defs, no-check-untyped-defs
@ -894,6 +895,36 @@ class AllStates:
return "<template AllStates>"
class StateTranslated:
"""Class to represent a translated state in a template."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize all states."""
self._hass = hass
def __call__(self, entity_id: str) -> str | None:
"""Retrieve translated state if available."""
state = _get_state_if_valid(self._hass, entity_id)
if state is None:
return STATE_UNKNOWN
state_value = state.state
domain = state.domain
device_class = state.attributes.get("device_class")
entry = entity_registry.async_get(self._hass).async_get(entity_id)
platform = None if entry is None else entry.platform
translation_key = None if entry is None else entry.translation_key
return async_translate_state(
self._hass, state_value, domain, platform, translation_key, device_class
)
def __repr__(self) -> str:
"""Representation of Translated state."""
return "<template StateTranslated>"
class DomainStates:
"""Class to expose a specific HA domain as attributes."""
@ -2626,6 +2657,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"is_state_attr",
"state_attr",
"states",
"state_translated",
"has_value",
"utcnow",
"now",
@ -2676,6 +2708,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["state_attr"] = self.globals["state_attr"]
self.globals["states"] = AllStates(hass)
self.filters["states"] = self.globals["states"]
self.globals["state_translated"] = StateTranslated(hass)
self.filters["state_translated"] = self.globals["state_translated"]
self.globals["has_value"] = hassfunction(has_value)
self.filters["has_value"] = self.globals["has_value"]
self.tests["has_value"] = hassfunction(has_value, pass_eval_context)
@ -2688,7 +2722,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
def is_safe_callable(self, obj):
"""Test if callback is safe."""
return isinstance(obj, AllStates) or super().is_safe_callable(obj)
return isinstance(
obj, (AllStates, StateTranslated)
) or super().is_safe_callable(obj)
def is_safe_attribute(self, obj, attr, value):
"""Test if attribute is safe."""

View File

@ -7,7 +7,13 @@ import logging
import string
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_CORE_CONFIG_UPDATE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.loader import (
Integration,
async_get_config_flows,
@ -199,12 +205,11 @@ class _TranslationCache:
self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {}
self.lock = asyncio.Lock()
async def async_fetch(
async def async_load(
self,
language: str,
category: str,
components: set[str],
) -> dict[str, str]:
) -> None:
"""Load resources into the cache."""
loaded = self.loaded.setdefault(language, set())
if components_to_load := components - loaded:
@ -218,6 +223,24 @@ class _TranslationCache:
if components_to_load := components - loaded:
await self._async_load(language, components_to_load)
async def async_fetch(
self,
language: str,
category: str,
components: set[str],
) -> dict[str, str]:
"""Load resources into the cache and return them."""
await self.async_load(language, components)
return self.get_cached(language, category, components)
def get_cached(
self,
language: str,
category: str,
components: set[str],
) -> dict[str, str]:
"""Read resources from the cache."""
category_cache = self.cache.get(language, {}).get(category, {})
# If only one component was requested, return it directly
# to avoid merging the dictionaries and keeping additional
@ -354,14 +377,67 @@ async def async_get_translations(
) -> dict[str, str]:
"""Return all backend translations.
If integration specified, load it for that one.
Otherwise default to loaded integrations combined with config flow
If integration is specified, load it for that one.
Otherwise, default to loaded integrations combined with config flow
integrations if config_flow is true.
"""
if integrations is None and config_flow:
components = (await async_get_config_flows(hass)) - hass.config.components
else:
components = _async_get_components(hass, category, integrations)
cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE]
return await cache.async_fetch(language, category, components)
async def _async_load_translations(
hass: HomeAssistant,
language: str,
category: str,
integration: str | None,
) -> None:
"""Prime backend translation cache.
If integration is not specified, translation cache is primed for all loaded integrations.
"""
components = _async_get_components(
hass, category, [integration] if integration is not None else None
)
cache = hass.data[TRANSLATION_FLATTEN_CACHE]
await cache.async_load(language, components)
@callback
def async_get_cached_translations(
hass: HomeAssistant,
language: str,
category: str,
integration: str | None = None,
) -> dict[str, str]:
"""Return all cached backend translations.
If integration is specified, return translations for it.
Otherwise, default to all loaded integrations.
"""
components = _async_get_components(
hass, category, [integration] if integration is not None else None
)
cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE]
return cache.get_cached(language, category, components)
@callback
def _async_get_components(
hass: HomeAssistant,
category: str,
integrations: Iterable[str] | None = None,
) -> set[str]:
"""Return a set of components for which translations should be loaded."""
if integrations is not None:
components = set(integrations)
elif config_flow:
components = (await async_get_config_flows(hass)) - hass.config.components
elif category in ("state", "entity_component", "services"):
components = hass.config.components
else:
@ -369,10 +445,91 @@ async def async_get_translations(
components = {
component for component in hass.config.components if "." not in component
}
return components
if TRANSLATION_FLATTEN_CACHE in hass.data:
cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE]
else:
cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass)
return await cache.async_fetch(language, category, components)
async def _async_load_state_translations_to_cache(
hass: HomeAssistant,
language: str,
integration: str | None,
) -> None:
"""Load state translations to cache."""
await _async_load_translations(hass, language, "entity", integration)
await _async_load_translations(hass, language, "state", integration)
await _async_load_translations(hass, language, "entity_component", integration)
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Create translation cache and register listeners for translation loaders.
Listeners load translations for every loaded component and after config change.
"""
hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass)
async def load_translations(event: Event) -> None:
if "language" in event.data:
language = hass.config.language
_LOGGER.debug("Loading translations for language: %s", language)
await _async_load_state_translations_to_cache(hass, language, None)
async def load_translations_for_component(event: Event) -> None:
component = event.data.get("component")
# Platforms don't have their own translations, skip them
if component is None or "." in str(component):
return
language = hass.config.language
_LOGGER.debug(
"Loading translations for language: %s and component: %s",
hass.config.language,
component,
)
await _async_load_state_translations_to_cache(hass, language, component)
hass.bus.async_listen(EVENT_COMPONENT_LOADED, load_translations_for_component)
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, load_translations)
@callback
def async_translate_state(
hass: HomeAssistant,
state: str,
domain: str,
platform: str | None,
translation_key: str | None,
device_class: str | None,
) -> str:
"""Translate provided state using cached translations for currently selected language."""
if state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
return state
language = hass.config.language
if platform is not None and translation_key is not None:
localize_key = (
f"component.{platform}.entity.{domain}.{translation_key}.state.{state}"
)
translations = async_get_cached_translations(hass, language, "entity")
if localize_key in translations:
return translations[localize_key]
translations = async_get_cached_translations(hass, language, "entity_component")
if device_class is not None:
localize_key = (
f"component.{domain}.entity_component.{device_class}.state.{state}"
)
if localize_key in translations:
return translations[localize_key]
localize_key = f"component.{domain}.entity_component._.state.{state}"
if localize_key in translations:
return translations[localize_key]
translations = async_get_cached_translations(hass, language, "state", domain)
if device_class is not None:
localize_key = f"component.{domain}.state.{device_class}.{state}"
if localize_key in translations:
return translations[localize_key]
localize_key = f"component.{domain}.state._.{state}"
if localize_key in translations:
return translations[localize_key]
return state

View File

@ -69,6 +69,7 @@ from homeassistant.helpers import (
restore_state,
restore_state as rs,
storage,
translation,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@ -267,6 +268,11 @@ async def async_test_home_assistant(event_loop, load_registries=True):
# Load the registries
entity.async_setup(hass)
loader.async_setup(hass)
# setup translation cache instead of calling translation.async_setup(hass)
hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache(
hass
)
if load_registries:
with patch(
"homeassistant.helpers.storage.Store.async_load", return_value=None

View File

@ -38,6 +38,7 @@ from homeassistant.helpers import (
entity,
entity_registry as er,
template,
translation,
)
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.json import json_dumps
@ -1955,6 +1956,129 @@ def test_states_function(hass: HomeAssistant) -> None:
assert tpl.async_render() == "available"
async def test_state_translated(
hass: HomeAssistant, entity_registry: er.EntityRegistry
):
"""Test state_translated method."""
assert await async_setup_component(
hass,
"binary_sensor",
{
"binary_sensor": {
"platform": "group",
"name": "Grouped",
"entities": ["binary_sensor.first", "binary_sensor.second"],
}
},
)
await hass.async_block_till_done()
await translation._async_load_state_translations_to_cache(hass, "en", None)
hass.states.async_set("switch.without_translations", "on", attributes={})
hass.states.async_set("binary_sensor.without_device_class", "on", attributes={})
hass.states.async_set(
"binary_sensor.with_device_class", "on", attributes={"device_class": "motion"}
)
hass.states.async_set(
"binary_sensor.with_unknown_device_class",
"on",
attributes={"device_class": "unknown_class"},
)
hass.states.async_set(
"some_domain.with_device_class_1",
"off",
attributes={"device_class": "some_device_class"},
)
hass.states.async_set(
"some_domain.with_device_class_2",
"foo",
attributes={"device_class": "some_device_class"},
)
hass.states.async_set("domain.is_unavailable", "unavailable", attributes={})
hass.states.async_set("domain.is_unknown", "unknown", attributes={})
config_entry = MockConfigEntry(domain="light")
entity_registry.async_get_or_create(
"light",
"hue",
"5678",
config_entry=config_entry,
translation_key="translation_key",
)
hass.states.async_set("light.hue_5678", "on", attributes={})
tpl = template.Template(
'{{ state_translated("switch.without_translations") }}', hass
)
assert tpl.async_render() == "on"
tp2 = template.Template(
'{{ state_translated("binary_sensor.without_device_class") }}', hass
)
assert tp2.async_render() == "On"
tpl3 = template.Template(
'{{ state_translated("binary_sensor.with_device_class") }}', hass
)
assert tpl3.async_render() == "Detected"
tpl4 = template.Template(
'{{ state_translated("binary_sensor.with_unknown_device_class") }}', hass
)
assert tpl4.async_render() == "On"
with pytest.raises(TemplateError):
template.Template(
'{{ state_translated("contextfunction") }}', hass
).async_render()
tpl6 = template.Template('{{ state_translated("switch.invalid") }}', hass)
assert tpl6.async_render() == "unknown"
with pytest.raises(TemplateError):
template.Template('{{ state_translated("-invalid") }}', hass).async_render()
def mock_get_cached_translations(
_hass: HomeAssistant,
_language: str,
category: str,
_integrations: Iterable[str] | None = None,
):
if category == "entity":
return {
"component.hue.entity.light.translation_key.state.on": "state_is_on"
}
if category == "state":
return {
"component.some_domain.state.some_device_class.off": "state_is_off",
"component.some_domain.state._.foo": "state_is_foo",
}
return {}
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
side_effect=mock_get_cached_translations,
):
tpl8 = template.Template('{{ state_translated("light.hue_5678") }}', hass)
assert tpl8.async_render() == "state_is_on"
tpl9 = template.Template(
'{{ state_translated("some_domain.with_device_class_1") }}', hass
)
assert tpl9.async_render() == "state_is_off"
tpl10 = template.Template(
'{{ state_translated("some_domain.with_device_class_2") }}', hass
)
assert tpl10.async_render() == "state_is_foo"
tpl11 = template.Template('{{ state_translated("domain.is_unavailable") }}', hass)
assert tpl11.async_render() == "unavailable"
tpl12 = template.Template('{{ state_translated("domain.is_unknown") }}', hass)
assert tpl12.async_render() == "unknown"
def test_has_value(hass: HomeAssistant) -> None:
"""Test has_value method."""
hass.states.async_set("test.value1", 1)

View File

@ -2,10 +2,11 @@
import asyncio
from os import path
import pathlib
from unittest.mock import Mock, patch
from unittest.mock import Mock, call, patch
import pytest
from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import HomeAssistant
from homeassistant.generated import config_flows
from homeassistant.helpers import translation
@ -510,3 +511,217 @@ async def test_custom_component_translations(
hass.config.components.add("test_embedded")
hass.config.components.add("test_package")
assert await translation.async_get_translations(hass, "en", "state") == {}
async def test_load_state_translations_to_cache(
hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None
):
"""Test the load state translations to cache helper."""
with patch(
"homeassistant.helpers.translation._async_load_translations",
) as mock:
await translation._async_load_state_translations_to_cache(hass, "en", None)
mock.assert_has_calls(
[
call(hass, "en", "entity", None),
call(hass, "en", "state", None),
call(hass, "en", "entity_component", None),
]
)
with patch(
"homeassistant.helpers.translation._async_load_translations",
) as mock:
await translation._async_load_state_translations_to_cache(
hass, "en", "some_integration"
)
mock.assert_has_calls(
[
call(hass, "en", "entity", "some_integration"),
call(hass, "en", "state", "some_integration"),
call(hass, "en", "entity_component", "some_integration"),
]
)
async def test_get_cached_translations(
hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None
):
"""Test the get cached translations helper."""
translations = translation.async_get_cached_translations(hass, "en", "state")
assert translations == {}
assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}})
await hass.async_block_till_done()
await translation._async_load_state_translations_to_cache(hass, "en", None)
translations = translation.async_get_cached_translations(hass, "en", "state")
assert translations["component.switch.state.string1"] == "Value 1"
assert translations["component.switch.state.string2"] == "Value 2"
await translation._async_load_state_translations_to_cache(hass, "de", None)
translations = translation.async_get_cached_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"
# Test a partial translation
await translation._async_load_state_translations_to_cache(hass, "es", None)
translations = translation.async_get_cached_translations(hass, "es", "state")
assert translations["component.switch.state.string1"] == "Spanish Value 1"
assert translations["component.switch.state.string2"] == "Value 2"
# Test that an untranslated language falls back to English.
await translation._async_load_state_translations_to_cache(
hass, "invalid-language", None
)
translations = translation.async_get_cached_translations(
hass, "invalid-language", "state"
)
assert translations["component.switch.state.string1"] == "Value 1"
assert translations["component.switch.state.string2"] == "Value 2"
async def test_setup(hass: HomeAssistant):
"""Test the setup load listeners helper."""
translation.async_setup(hass)
with patch(
"homeassistant.helpers.translation._async_load_state_translations_to_cache",
) as mock:
hass.bus.async_fire(EVENT_COMPONENT_LOADED, {"component": "loaded_component"})
await hass.async_block_till_done()
mock.assert_called_once_with(hass, hass.config.language, "loaded_component")
with patch(
"homeassistant.helpers.translation._async_load_state_translations_to_cache",
) as mock:
hass.bus.async_fire(EVENT_COMPONENT_LOADED, {"component": "config.component"})
await hass.async_block_till_done()
mock.assert_not_called()
with patch(
"homeassistant.helpers.translation._async_load_state_translations_to_cache",
) as mock:
hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, {"language": "en"})
await hass.async_block_till_done()
mock.assert_called_once_with(hass, hass.config.language, None)
with patch(
"homeassistant.helpers.translation._async_load_state_translations_to_cache",
) as mock:
hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, {})
await hass.async_block_till_done()
mock.assert_not_called()
async def test_translate_state(hass: HomeAssistant):
"""Test the state translation helper."""
result = translation.async_translate_state(
hass, "unavailable", "binary_sensor", "platform", "translation_key", None
)
assert result == "unavailable"
result = translation.async_translate_state(
hass, "unknown", "binary_sensor", "platform", "translation_key", None
)
assert result == "unknown"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.platform.entity.binary_sensor.translation_key.state.on": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", "translation_key", None
)
mock.assert_called_once_with(hass, hass.config.language, "entity")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.binary_sensor.entity_component.device_class.state.on": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", None, "device_class"
)
mock.assert_called_once_with(hass, hass.config.language, "entity_component")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.binary_sensor.entity_component._.state.on": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", None, None
)
mock.assert_called_once_with(hass, hass.config.language, "entity_component")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={"component.binary_sensor.state.device_class.on": "TRANSLATED"},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", None, "device_class"
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity_component"),
call(hass, hass.config.language, "state", "binary_sensor"),
]
)
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={"component.binary_sensor.state._.on": "TRANSLATED"},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", None, None
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity_component"),
call(hass, hass.config.language, "state", "binary_sensor"),
]
)
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", None, None
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity_component"),
call(hass, hass.config.language, "state", "binary_sensor"),
]
)
assert result == "on"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={},
) as mock:
result = translation.async_translate_state(
hass, "on", "binary_sensor", "platform", "translation_key", "device_class"
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity"),
call(hass, hass.config.language, "entity_component"),
call(hass, hass.config.language, "state", "binary_sensor"),
]
)
assert result == "on"