Home Assistant Unofficial Reference 2024.12.1
translation.py
Go to the documentation of this file.
1 """Translation string lookup helpers."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Iterable, Mapping
7 from contextlib import suppress
8 from dataclasses import dataclass
9 import logging
10 import pathlib
11 import string
12 from typing import Any
13 
14 from homeassistant.const import (
15  EVENT_CORE_CONFIG_UPDATE,
16  STATE_UNAVAILABLE,
17  STATE_UNKNOWN,
18 )
19 from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
20 from homeassistant.loader import (
21  Integration,
22  async_get_config_flows,
23  async_get_integrations,
24  bind_hass,
25 )
26 from homeassistant.util.json import load_json
27 
28 from . import singleton
29 
30 _LOGGER = logging.getLogger(__name__)
31 
32 TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache"
33 LOCALE_EN = "en"
34 
35 
37  prefix: str, data: dict[str, dict[str, Any] | str]
38 ) -> dict[str, str]:
39  """Return a flattened representation of dict data."""
40  output: dict[str, str] = {}
41  for key, value in data.items():
42  if isinstance(value, dict):
43  output.update(recursive_flatten(f"{prefix}{key}.", value))
44  else:
45  output[f"{prefix}{key}"] = value
46  return output
47 
48 
50  translation_files: dict[str, dict[str, pathlib.Path]],
51 ) -> dict[str, dict[str, Any]]:
52  """Load and parse translation.json files."""
53  loaded: dict[str, dict[str, Any]] = {}
54  for language, component_translation_file in translation_files.items():
55  loaded_for_language: dict[str, Any] = {}
56  loaded[language] = loaded_for_language
57 
58  for component, translation_file in component_translation_file.items():
59  loaded_json = load_json(translation_file)
60 
61  if not isinstance(loaded_json, dict):
62  _LOGGER.warning(
63  "Translation file is unexpected type %s. Expected dict for %s",
64  type(loaded_json),
65  translation_file,
66  )
67  continue
68 
69  loaded_for_language[component] = loaded_json
70 
71  return loaded
72 
73 
75  translation_strings: dict[str, dict[str, dict[str, Any] | str]],
76  components: set[str],
77  category: str,
78 ) -> dict[str, dict[str, Any] | str]:
79  """Build the resources response for the given components."""
80  # Build response
81  return {
82  component: category_strings
83  for component in components
84  if (component_strings := translation_strings.get(component))
85  and (category_strings := component_strings.get(category))
86  }
87 
88 
90  hass: HomeAssistant,
91  languages: Iterable[str],
92  components: set[str],
93  integrations: dict[str, Integration],
94 ) -> dict[str, dict[str, Any]]:
95  """Load translations."""
96  translations_by_language: dict[str, dict[str, Any]] = {}
97  # Determine paths of missing components/platforms
98  files_to_load_by_language: dict[str, dict[str, pathlib.Path]] = {}
99  loaded_translations_by_language: dict[str, dict[str, Any]] = {}
100  has_files_to_load = False
101  for language in languages:
102  file_name = f"{language}.json"
103  files_to_load: dict[str, pathlib.Path] = {
104  domain: integration.file_path / "translations" / file_name
105  for domain in components
106  if (
107  (integration := integrations.get(domain))
108  and integration.has_translations
109  )
110  }
111  files_to_load_by_language[language] = files_to_load
112  has_files_to_load |= bool(files_to_load)
113 
114  if has_files_to_load:
115  loaded_translations_by_language = await hass.async_add_executor_job(
116  _load_translations_files_by_language, files_to_load_by_language
117  )
118 
119  for language in languages:
120  loaded_translations = loaded_translations_by_language.setdefault(language, {})
121  for domain in components:
122  # Translations that miss "title" will get integration put in.
123  component_translations = loaded_translations.setdefault(domain, {})
124  if "title" not in component_translations and (
125  integration := integrations.get(domain)
126  ):
127  component_translations["title"] = integration.name
128 
129  translations_by_language.setdefault(language, {}).update(loaded_translations)
130 
131  return translations_by_language
132 
133 
134 @dataclass(slots=True)
136  """Data for the translation cache.
137 
138  This class contains data that is designed to be shared
139  between multiple instances of the translation cache so
140  we only have to load the data once.
141  """
142 
143  loaded: dict[str, set[str]]
144  cache: dict[str, dict[str, dict[str, dict[str, str]]]]
145 
146 
148  """Cache for flattened translations."""
149 
150  __slots__ = ("hass", "cache_data", "lock")
151 
152  def __init__(self, hass: HomeAssistant) -> None:
153  """Initialize the cache."""
154  self.hasshass = hass
155  self.cache_datacache_data = _TranslationsCacheData({}, {})
156  self.locklock = asyncio.Lock()
157 
158  @callback
159  def async_is_loaded(self, language: str, components: set[str]) -> bool:
160  """Return if the given components are loaded for the language."""
161  return components.issubset(self.cache_datacache_data.loaded.get(language, set()))
162 
163  async def async_load(
164  self,
165  language: str,
166  components: set[str],
167  ) -> None:
168  """Load resources into the cache."""
169  loaded = self.cache_datacache_data.loaded.setdefault(language, set())
170  if components_to_load := components - loaded:
171  # Translations are never unloaded so if there are no components to load
172  # we can skip the lock which reduces contention when multiple different
173  # translations categories are being fetched at the same time which is
174  # common from the frontend.
175  async with self.locklock:
176  # Check components to load again, as another task might have loaded
177  # them while we were waiting for the lock.
178  if components_to_load := components - loaded:
179  await self._async_load_async_load(language, components_to_load)
180 
181  async def async_fetch(
182  self,
183  language: str,
184  category: str,
185  components: set[str],
186  ) -> dict[str, str]:
187  """Load resources into the cache and return them."""
188  await self.async_loadasync_load(language, components)
189 
190  return self.get_cachedget_cached(language, category, components)
191 
193  self,
194  language: str,
195  category: str,
196  components: set[str],
197  ) -> dict[str, str]:
198  """Read resources from the cache."""
199  category_cache = self.cache_datacache_data.cache.get(language, {}).get(category, {})
200  # If only one component was requested, return it directly
201  # to avoid merging the dictionaries and keeping additional
202  # copies of the same data in memory.
203  if len(components) == 1 and (component := next(iter(components))):
204  return category_cache.get(component, {})
205 
206  result: dict[str, str] = {}
207  for component in components.intersection(category_cache):
208  result.update(category_cache[component])
209  return result
210 
211  async def _async_load(self, language: str, components: set[str]) -> None:
212  """Populate the cache for a given set of components."""
213  loaded = self.cache_datacache_data.loaded
214  _LOGGER.debug(
215  "Cache miss for %s: %s",
216  language,
217  components,
218  )
219  # Fetch the English resources, as a fallback for missing keys
220  languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language]
221 
222  integrations: dict[str, Integration] = {}
223  ints_or_excs = await async_get_integrations(self.hasshass, components)
224  for domain, int_or_exc in ints_or_excs.items():
225  if isinstance(int_or_exc, Exception):
226  _LOGGER.warning(
227  "Failed to load integration for translation: %s", int_or_exc
228  )
229  continue
230  integrations[domain] = int_or_exc
231 
232  translation_by_language_strings = await _async_get_component_strings(
233  self.hasshass, languages, components, integrations
234  )
235 
236  # English is always the fallback language so we load them first
237  self._build_category_cache_build_category_cache(
238  language, components, translation_by_language_strings[LOCALE_EN]
239  )
240 
241  if language != LOCALE_EN:
242  # Now overlay the requested language on top of the English
243  self._build_category_cache_build_category_cache(
244  language, components, translation_by_language_strings[language]
245  )
246 
247  loaded_english_components = loaded.setdefault(LOCALE_EN, set())
248  # Since we just loaded english anyway we can avoid loading
249  # again if they switch back to english.
250  if loaded_english_components.isdisjoint(components):
251  self._build_category_cache_build_category_cache(
252  LOCALE_EN, components, translation_by_language_strings[LOCALE_EN]
253  )
254  loaded_english_components.update(components)
255 
256  loaded[language].update(components)
257 
259  self,
260  language: str,
261  updated_resources: dict[str, str],
262  cached_resources: dict[str, str] | None = None,
263  ) -> dict[str, str]:
264  """Validate if updated resources have same placeholders as cached resources."""
265  if cached_resources is None:
266  return updated_resources
267 
268  mismatches: set[str] = set()
269 
270  for key, value in updated_resources.items():
271  if key not in cached_resources:
272  continue
273  try:
274  tuples = list(string.Formatter().parse(value))
275  except ValueError:
276  _LOGGER.error(
277  ("Error while parsing localized (%s) string %s"), language, key
278  )
279  continue
280  updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None}
281 
282  tuples = list(string.Formatter().parse(cached_resources[key]))
283  cached_placeholders = {tup[1] for tup in tuples if tup[1] is not None}
284  if updated_placeholders != cached_placeholders:
285  _LOGGER.error(
286  (
287  "Validation of translation placeholders for localized (%s) string "
288  "%s failed: (%s != %s)"
289  ),
290  language,
291  key,
292  updated_placeholders,
293  cached_placeholders,
294  )
295  mismatches.add(key)
296 
297  for mismatch in mismatches:
298  del updated_resources[mismatch]
299 
300  return updated_resources
301 
302  @callback
304  self,
305  language: str,
306  components: set[str],
307  translation_strings: dict[str, dict[str, Any]],
308  ) -> None:
309  """Extract resources into the cache."""
310  resource: dict[str, Any] | str
311  cached = self.cache_datacache_data.cache.setdefault(language, {})
312  categories = {
313  category
314  for component in translation_strings.values()
315  for category in component
316  }
317 
318  for category in categories:
319  new_resources = build_resources(translation_strings, components, category)
320  category_cache = cached.setdefault(category, {})
321 
322  for component, resource in new_resources.items():
323  component_cache = category_cache.setdefault(component, {})
324 
325  if not isinstance(resource, dict):
326  component_cache[f"component.{component}.{category}"] = resource
327  continue
328 
329  prefix = f"component.{component}.{category}."
330  flat = recursive_flatten(prefix, resource)
331  flat = self._validate_placeholders_validate_placeholders(language, flat, component_cache)
332  component_cache.update(flat)
333 
334 
335 @bind_hass
337  hass: HomeAssistant,
338  language: str,
339  category: str,
340  integrations: Iterable[str] | None = None,
341  config_flow: bool | None = None,
342 ) -> dict[str, str]:
343  """Return all backend translations.
344 
345  If integration is specified, load it for that one.
346  Otherwise, default to loaded integrations combined with config flow
347  integrations if config_flow is true.
348  """
349  if integrations is None and config_flow:
350  components = (await async_get_config_flows(hass)) - hass.config.components
351  elif integrations is not None:
352  components = set(integrations)
353  else:
354  components = hass.config.top_level_components
355 
356  return await _async_get_translations_cache(hass).async_fetch(
357  language, category, components
358  )
359 
360 
361 @callback
363  hass: HomeAssistant,
364  language: str,
365  category: str,
366  integration: str | None = None,
367 ) -> dict[str, str]:
368  """Return all cached backend translations.
369 
370  If integration is specified, return translations for it.
371  Otherwise, default to all loaded integrations.
372  """
373  components = {integration} if integration else hass.config.top_level_components
374  return _async_get_translations_cache(hass).get_cached(
375  language, category, components
376  )
377 
378 
379 @singleton.singleton(TRANSLATION_FLATTEN_CACHE)
380 def _async_get_translations_cache(hass: HomeAssistant) -> _TranslationCache:
381  """Return the translation cache."""
382  return _TranslationCache(hass)
383 
384 
385 @callback
386 def async_setup(hass: HomeAssistant) -> None:
387  """Create translation cache and register listeners for translation loaders.
388 
389  Listeners load translations for every loaded component and after config change.
390  """
391  cache = _TranslationCache(hass)
392  current_language = hass.config.language
394 
395  @callback
396  def _async_load_translations_filter(event_data: Mapping[str, Any]) -> bool:
397  """Filter out unwanted events."""
398  nonlocal current_language
399  if (
400  new_language := event_data.get("language")
401  ) and new_language != current_language:
402  current_language = new_language
403  return True
404  return False
405 
406  async def _async_load_translations(event: Event) -> None:
407  new_language = event.data["language"]
408  _LOGGER.debug("Loading translations for language: %s", new_language)
409  await cache.async_load(new_language, hass.config.components)
410 
411  hass.bus.async_listen(
412  EVENT_CORE_CONFIG_UPDATE,
413  _async_load_translations,
414  event_filter=_async_load_translations_filter,
415  )
416 
417 
418 async def async_load_integrations(hass: HomeAssistant, integrations: set[str]) -> None:
419  """Load translations for integrations."""
421  hass.config.language, integrations
422  )
423 
424 
425 @callback
426 def async_translations_loaded(hass: HomeAssistant, components: set[str]) -> bool:
427  """Return if the given components are loaded for the language."""
428  return _async_get_translations_cache(hass).async_is_loaded(
429  hass.config.language, components
430  )
431 
432 
433 @callback
435  translation_domain: str,
436  translation_key: str,
437  translation_placeholders: dict[str, str] | None = None,
438 ) -> str:
439  """Return a translated exception message.
440 
441  Defaults to English, requires translations to already be cached.
442  """
443  language = "en"
444  hass = async_get_hass()
445  localize_key = (
446  f"component.{translation_domain}.exceptions.{translation_key}.message"
447  )
448  translations = async_get_cached_translations(hass, language, "exceptions")
449  if localize_key in translations:
450  if message := translations[localize_key]:
451  message = message.rstrip(".")
452  if not translation_placeholders:
453  return message
454  with suppress(KeyError):
455  message = message.format(**translation_placeholders)
456  return message
457 
458  # We return the translation key when was not found in the cache
459  return translation_key
460 
461 
462 @callback
464  hass: HomeAssistant,
465  state: str,
466  domain: str,
467  platform: str | None,
468  translation_key: str | None,
469  device_class: str | None,
470 ) -> str:
471  """Translate provided state using cached translations for currently selected language."""
472  if state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
473  return state
474  language = hass.config.language
475  if platform is not None and translation_key is not None:
476  localize_key = (
477  f"component.{platform}.entity.{domain}.{translation_key}.state.{state}"
478  )
479  translations = async_get_cached_translations(hass, language, "entity")
480  if localize_key in translations:
481  return translations[localize_key]
482 
483  translations = async_get_cached_translations(hass, language, "entity_component")
484  if device_class is not None:
485  localize_key = (
486  f"component.{domain}.entity_component.{device_class}.state.{state}"
487  )
488  if localize_key in translations:
489  return translations[localize_key]
490  localize_key = f"component.{domain}.entity_component._.state.{state}"
491  if localize_key in translations:
492  return translations[localize_key]
493 
494  return state
None _async_load(self, str language, set[str] components)
Definition: translation.py:211
None _build_category_cache(self, str language, set[str] components, dict[str, dict[str, Any]] translation_strings)
Definition: translation.py:308
dict[str, str] _validate_placeholders(self, str language, dict[str, str] updated_resources, dict[str, str]|None cached_resources=None)
Definition: translation.py:263
dict[str, str] async_fetch(self, str language, str category, set[str] components)
Definition: translation.py:186
bool async_is_loaded(self, str language, set[str] components)
Definition: translation.py:159
dict[str, str] get_cached(self, str language, str category, set[str] components)
Definition: translation.py:197
None async_load(self, str language, set[str] components)
Definition: translation.py:167
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
HomeAssistant async_get_hass()
Definition: core.py:286
None async_load(HomeAssistant hass)
None async_setup(HomeAssistant hass)
Definition: translation.py:386
str async_get_exception_message(str translation_domain, str translation_key, dict[str, str]|None translation_placeholders=None)
Definition: translation.py:438
bool async_translations_loaded(HomeAssistant hass, set[str] components)
Definition: translation.py:426
dict[str, str] recursive_flatten(str prefix, dict[str, dict[str, Any]|str] data)
Definition: translation.py:38
str async_translate_state(HomeAssistant hass, str state, str domain, str|None platform, str|None translation_key, str|None device_class)
Definition: translation.py:470
_TranslationCache _async_get_translations_cache(HomeAssistant hass)
Definition: translation.py:380
dict[str, dict[str, Any]] _load_translations_files_by_language(dict[str, dict[str, pathlib.Path]] translation_files)
Definition: translation.py:51
dict[str, dict[str, Any]|str] build_resources(dict[str, dict[str, dict[str, Any]|str]] translation_strings, set[str] components, str category)
Definition: translation.py:78
dict[str, str] async_get_cached_translations(HomeAssistant hass, str language, str category, str|None integration=None)
Definition: translation.py:367
dict[str, str] async_get_translations(HomeAssistant hass, str language, str category, Iterable[str]|None integrations=None, bool|None config_flow=None)
Definition: translation.py:342
dict[str, dict[str, Any]] _async_get_component_strings(HomeAssistant hass, Iterable[str] languages, set[str] components, dict[str, Integration] integrations)
Definition: translation.py:94
None async_load_integrations(HomeAssistant hass, set[str] integrations)
Definition: translation.py:418
set[str] async_get_config_flows(HomeAssistant hass, Literal["device", "helper", "hub", "service"]|None type_filter=None)
Definition: loader.py:339
dict[str, Integration|Exception] async_get_integrations(HomeAssistant hass, Iterable[str] domains)
Definition: loader.py:1368
JsonValueType load_json(str|PathLike[str] filename, JsonValueType default=_SENTINEL)
Definition: json.py:66