Add support for placeholders in entity name translations (#104453)

* add placeholder support to entity name translation

* add negativ tests

* make property also available via description

* fix doc string in translation_placeholders()

* fix detection of placeholder

* validate placeholders for localized strings

* add test

* Cache translation_placeholders property

* Make translation_placeholders uncondotionally return dict

* Fall back to unsubstituted name in case of mismatch

* Only replace failing translations with English

* Update snapshots

* Blow up on non stable releases

* Fix test

* Update entity.py

---------

Co-authored-by: Erik <erik@montnemery.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Michael 2024-01-03 17:34:47 +01:00 committed by GitHub
parent d071299233
commit eb01998395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 425 additions and 24 deletions

View File

@ -43,7 +43,13 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
EntityCategory, EntityCategory,
) )
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.core import (
CALLBACK_TYPE,
Context,
HomeAssistant,
callback,
get_release_channel,
)
from homeassistant.exceptions import ( from homeassistant.exceptions import (
HomeAssistantError, HomeAssistantError,
InvalidStateError, InvalidStateError,
@ -245,6 +251,7 @@ class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True):
has_entity_name: bool = False has_entity_name: bool = False
name: str | UndefinedType | None = UNDEFINED name: str | UndefinedType | None = UNDEFINED
translation_key: str | None = None translation_key: str | None = None
translation_placeholders: Mapping[str, str] | None = None
unit_of_measurement: str | None = None unit_of_measurement: str | None = None
@ -429,6 +436,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"state", "state",
"supported_features", "supported_features",
"translation_key", "translation_key",
"translation_placeholders",
"unique_id", "unique_id",
"unit_of_measurement", "unit_of_measurement",
} }
@ -473,6 +481,9 @@ class Entity(
# If we reported this entity was added without its platform set # If we reported this entity was added without its platform set
_no_platform_reported = False _no_platform_reported = False
# If we reported the name translation placeholders do not match the name
_name_translation_placeholders_reported = False
# Protect for multiple updates # Protect for multiple updates
_update_staged = False _update_staged = False
@ -537,6 +548,7 @@ class Entity(
_attr_state: StateType = STATE_UNKNOWN _attr_state: StateType = STATE_UNKNOWN
_attr_supported_features: int | None = None _attr_supported_features: int | None = None
_attr_translation_key: str | None _attr_translation_key: str | None
_attr_translation_placeholders: Mapping[str, str]
_attr_unique_id: str | None = None _attr_unique_id: str | None = None
_attr_unit_of_measurement: str | None _attr_unit_of_measurement: str | None
@ -628,6 +640,29 @@ class Entity(
f".{self.translation_key}.name" f".{self.translation_key}.name"
) )
def _substitute_name_placeholders(self, name: str) -> str:
"""Substitute placeholders in entity name."""
try:
return name.format(**self.translation_placeholders)
except KeyError as err:
if not self._name_translation_placeholders_reported:
if get_release_channel() != "stable":
raise HomeAssistantError("Missing placeholder %s" % err) from err
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"Entity %s (%s) has translation placeholders '%s' which do not "
"match the name '%s', please %s"
),
self.entity_id,
type(self),
self.translation_placeholders,
name,
report_issue,
)
self._name_translation_placeholders_reported = True
return name
def _name_internal( def _name_internal(
self, self,
device_class_name: str | None, device_class_name: str | None,
@ -643,7 +678,7 @@ class Entity(
): ):
if TYPE_CHECKING: if TYPE_CHECKING:
assert isinstance(name, str) assert isinstance(name, str)
return name return self._substitute_name_placeholders(name)
if hasattr(self, "entity_description"): if hasattr(self, "entity_description"):
description_name = self.entity_description.name description_name = self.entity_description.name
if description_name is UNDEFINED and self._default_to_device_class_name(): if description_name is UNDEFINED and self._default_to_device_class_name():
@ -853,6 +888,16 @@ class Entity(
return self.entity_description.translation_key return self.entity_description.translation_key
return None return None
@final
@cached_property
def translation_placeholders(self) -> Mapping[str, str]:
"""Return the translation placeholders for translated entity's name."""
if hasattr(self, "_attr_translation_placeholders"):
return self._attr_translation_placeholders
if hasattr(self, "entity_description"):
return self.entity_description.translation_placeholders or {}
return {}
# DO NOT OVERWRITE # DO NOT OVERWRITE
# These properties and methods are either managed by Home Assistant or they # These properties and methods are either managed by Home Assistant or they
# are used to perform a very specific function. Overwriting these may # are used to perform a very specific function. Overwriting these may

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
import logging import logging
import string
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -242,6 +243,42 @@ class _TranslationCache:
self.loaded[language].update(components) self.loaded[language].update(components)
def _validate_placeholders(
self,
language: str,
updated_resources: dict[str, Any],
cached_resources: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate if updated resources have same placeholders as cached resources."""
if cached_resources is None:
return updated_resources
mismatches: set[str] = set()
for key, value in updated_resources.items():
if key not in cached_resources:
continue
tuples = list(string.Formatter().parse(value))
updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None}
tuples = list(string.Formatter().parse(cached_resources[key]))
cached_placeholders = {tup[1] for tup in tuples if tup[1] is not None}
if updated_placeholders != cached_placeholders:
_LOGGER.error(
(
"Validation of translation placeholders for localized (%s) string "
"%s failed"
),
language,
key,
)
mismatches.add(key)
for mismatch in mismatches:
del updated_resources[mismatch]
return updated_resources
@callback @callback
def _build_category_cache( def _build_category_cache(
self, self,
@ -274,12 +311,14 @@ class _TranslationCache:
).setdefault(category, {}) ).setdefault(category, {})
if isinstance(resource, dict): if isinstance(resource, dict):
category_cache.update( resources_flatten = recursive_flatten(
recursive_flatten(
f"component.{component}.{category}.", f"component.{component}.{category}.",
resource, resource,
) )
resources_flatten = self._validate_placeholders(
language, resources_flatten, category_cache
) )
category_cache.update(resources_flatten)
else: else:
category_cache[f"component.{component}.{category}"] = resource category_cache[f"component.{component}.{category}"] = resource

View File

@ -11,11 +11,12 @@
'key': 'blah', 'key': 'blah',
'name': <UndefinedType._singleton: 0>, 'name': <UndefinedType._singleton: 0>,
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_entity_description_as_dataclass.1 # name: test_entity_description_as_dataclass.1
"EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=<UndefinedType._singleton: 0>, translation_key=None, unit_of_measurement=None)" "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=<UndefinedType._singleton: 0>, translation_key=None, translation_placeholders=None, unit_of_measurement=None)"
# --- # ---
# name: test_extending_entity_description # name: test_extending_entity_description
dict({ dict({
@ -30,11 +31,12 @@
'key': 'blah', 'key': 'blah',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.1 # name: test_extending_entity_description.1
"test_extending_entity_description.<locals>.FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.10 # name: test_extending_entity_description.10
dict({ dict({
@ -50,11 +52,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.11 # name: test_extending_entity_description.11
"test_extending_entity_description.<locals>.ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.12 # name: test_extending_entity_description.12
dict({ dict({
@ -70,11 +73,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.13 # name: test_extending_entity_description.13
"test_extending_entity_description.<locals>.ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.14 # name: test_extending_entity_description.14
dict({ dict({
@ -90,11 +94,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.15 # name: test_extending_entity_description.15
"test_extending_entity_description.<locals>.ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.16 # name: test_extending_entity_description.16
dict({ dict({
@ -110,11 +115,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.17 # name: test_extending_entity_description.17
"test_extending_entity_description.<locals>.ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.18 # name: test_extending_entity_description.18
dict({ dict({
@ -130,11 +136,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.19 # name: test_extending_entity_description.19
"test_extending_entity_description.<locals>.ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.2 # name: test_extending_entity_description.2
dict({ dict({
@ -149,6 +156,7 @@
'key': 'blah', 'key': 'blah',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -166,11 +174,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.21 # name: test_extending_entity_description.21
"test_extending_entity_description.<locals>.ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.22 # name: test_extending_entity_description.22
dict({ dict({
@ -186,11 +195,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.23 # name: test_extending_entity_description.23
"test_extending_entity_description.<locals>.ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.24 # name: test_extending_entity_description.24
dict({ dict({
@ -206,11 +216,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.25 # name: test_extending_entity_description.25
"test_extending_entity_description.<locals>.ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.26 # name: test_extending_entity_description.26
dict({ dict({
@ -226,11 +237,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.27 # name: test_extending_entity_description.27
"test_extending_entity_description.<locals>.ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.28 # name: test_extending_entity_description.28
dict({ dict({
@ -246,14 +258,15 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.29 # name: test_extending_entity_description.29
"test_extending_entity_description.<locals>.ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.3 # name: test_extending_entity_description.3
"test_extending_entity_description.<locals>.ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.30 # name: test_extending_entity_description.30
dict({ dict({
@ -267,11 +280,12 @@
'key': 'blah', 'key': 'blah',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.31 # name: test_extending_entity_description.31
"test_extending_entity_description.<locals>.CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" "test_extending_entity_description.<locals>.CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None)"
# --- # ---
# name: test_extending_entity_description.4 # name: test_extending_entity_description.4
dict({ dict({
@ -287,11 +301,12 @@
'key': 'blah', 'key': 'blah',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.5 # name: test_extending_entity_description.5
"test_extending_entity_description.<locals>.MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extension='ext', extra='foo')" "test_extending_entity_description.<locals>.MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extension='ext', extra='foo')"
# --- # ---
# name: test_extending_entity_description.6 # name: test_extending_entity_description.6
dict({ dict({
@ -307,11 +322,12 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.7 # name: test_extending_entity_description.7
"test_extending_entity_description.<locals>.ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---
# name: test_extending_entity_description.8 # name: test_extending_entity_description.8
dict({ dict({
@ -327,9 +343,10 @@
'mixin': 'mixin', 'mixin': 'mixin',
'name': 'name', 'name': 'name',
'translation_key': None, 'translation_key': None,
'translation_placeholders': None,
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_extending_entity_description.9 # name: test_extending_entity_description.9
"test_extending_entity_description.<locals>.ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" "test_extending_entity_description.<locals>.ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')"
# --- # ---

View File

@ -1137,6 +1137,203 @@ async def test_friendly_name_description_device_class_name(
) )
@pytest.mark.parametrize(
(
"has_entity_name",
"translation_key",
"translations",
"placeholders",
"expected_friendly_name",
),
(
(False, None, None, None, "Entity Blu"),
(True, None, None, None, "Device Bla Entity Blu"),
(
True,
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "English ent"
},
},
None,
"Device Bla English ent",
),
(
True,
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "{placeholder} English ent"
},
},
{"placeholder": "special"},
"Device Bla special English ent",
),
(
True,
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "English ent {placeholder}"
},
},
{"placeholder": "special"},
"Device Bla English ent special",
),
),
)
async def test_entity_name_translation_placeholders(
hass: HomeAssistant,
has_entity_name: bool,
translation_key: str | None,
translations: dict[str, str] | None,
placeholders: dict[str, str] | None,
expected_friendly_name: str | None,
) -> None:
"""Test friendly name when the entity name translation has placeholders."""
async def async_get_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
return translations[language]
ent = MockEntity(
unique_id="qwer",
device_info={
"identifiers": {("hue", "1234")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")},
"name": "Device Bla",
},
)
ent.entity_description = entity.EntityDescription(
"test",
has_entity_name=has_entity_name,
translation_key=translation_key,
name="Entity Blu",
)
if placeholders is not None:
ent._attr_translation_placeholders = placeholders
with patch(
"homeassistant.helpers.entity_platform.translation.async_get_translations",
side_effect=async_get_translations,
):
await _test_friendly_name(hass, ent, expected_friendly_name)
@pytest.mark.parametrize(
(
"translation_key",
"translations",
"placeholders",
"release_channel",
"expected_error",
),
(
(
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}"
},
},
{"placeholder": "special"},
"stable",
(
"has translation placeholders '{'placeholder': 'special'}' which do "
"not match the name '{placeholder} English ent {2ndplaceholder}'"
),
),
(
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}"
},
},
{"placeholder": "special"},
"beta",
"HomeAssistantError: Missing placeholder '2ndplaceholder'",
),
(
"test_entity",
{
"en": {
"component.test.entity.test_domain.test_entity.name": "{placeholder} English ent"
},
},
None,
"stable",
(
"has translation placeholders '{}' which do "
"not match the name '{placeholder} English ent'"
),
),
),
)
async def test_entity_name_translation_placeholder_errors(
hass: HomeAssistant,
translation_key: str | None,
translations: dict[str, str] | None,
placeholders: dict[str, str] | None,
release_channel: str,
expected_error: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test entity name translation has placeholder issues."""
async def async_get_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
return translations[language]
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Mock setup entry method."""
async_add_entities([ent])
return True
ent = MockEntity(
unique_id="qwer",
)
ent.entity_description = entity.EntityDescription(
"test",
has_entity_name=True,
translation_key=translation_key,
name="Entity Blu",
)
if placeholders is not None:
ent._attr_translation_placeholders = placeholders
platform = MockPlatform(async_setup_entry=async_setup_entry)
config_entry = MockConfigEntry(entry_id="super-mock-id")
config_entry.add_to_hass(hass)
entity_platform = MockEntityPlatform(
hass, platform_name=config_entry.domain, platform=platform
)
caplog.clear()
with patch(
"homeassistant.helpers.entity_platform.translation.async_get_translations",
side_effect=async_get_translations,
), patch(
"homeassistant.helpers.entity.get_release_channel", return_value=release_channel
):
await entity_platform.async_setup_entry(config_entry)
assert expected_error in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
("has_entity_name", "entity_name", "expected_friendly_name"), ("has_entity_name", "entity_name", "expected_friendly_name"),
( (

View File

@ -98,6 +98,77 @@ def test_load_translations_files(hass: HomeAssistant) -> None:
} }
@pytest.mark.parametrize(
("language", "expected_translation", "expected_errors"),
(
(
"en",
{
"component.test.entity.switch.other1.name": "Other 1",
"component.test.entity.switch.other2.name": "Other 2",
"component.test.entity.switch.other3.name": "Other 3",
"component.test.entity.switch.other4.name": "Other 4",
"component.test.entity.switch.outlet.name": "Outlet {placeholder}",
},
[],
),
(
"es",
{
"component.test.entity.switch.other1.name": "Otra 1",
"component.test.entity.switch.other2.name": "Otra 2",
"component.test.entity.switch.other3.name": "Otra 3",
"component.test.entity.switch.other4.name": "Otra 4",
"component.test.entity.switch.outlet.name": "Enchufe {placeholder}",
},
[],
),
(
"de",
{
# Correct
"component.test.entity.switch.other1.name": "Anderes 1",
# Translation has placeholder missing in English
"component.test.entity.switch.other2.name": "Other 2",
# Correct (empty translation)
"component.test.entity.switch.other3.name": "",
# Translation missing
"component.test.entity.switch.other4.name": "Other 4",
# Mismatch in placeholders
"component.test.entity.switch.outlet.name": "Outlet {placeholder}",
},
[
"component.test.entity.switch.other2.name",
"component.test.entity.switch.outlet.name",
],
),
),
)
async def test_load_translations_files_invalid_localized_placeholders(
hass: HomeAssistant,
enable_custom_integrations: None,
caplog: pytest.LogCaptureFixture,
language: str,
expected_translation: dict,
expected_errors: bool,
) -> None:
"""Test the load translation files with invalid localized placeholders."""
caplog.clear()
translations = await translation.async_get_translations(
hass, language, "entity", ["test"]
)
assert translations == expected_translation
assert ("Validation of translation placeholders" in caplog.text) == (
len(expected_errors) > 0
)
for expected_error in expected_errors:
assert (
f"Validation of translation placeholders for localized ({language}) string {expected_error} failed"
in caplog.text
)
async def test_get_translations( async def test_get_translations(
hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None
) -> None: ) -> None:

View File

@ -0,0 +1,10 @@
{
"entity": {
"switch": {
"other1": { "name": "Anderes 1" },
"other2": { "name": "Anderes 2 {placeholder}" },
"other3": { "name": "" },
"outlet": { "name": "Steckdose {something}" }
}
}
}

View File

@ -0,0 +1,11 @@
{
"entity": {
"switch": {
"other1": { "name": "Other 1" },
"other2": { "name": "Other 2" },
"other3": { "name": "Other 3" },
"other4": { "name": "Other 4" },
"outlet": { "name": "Outlet {placeholder}" }
}
}
}

View File

@ -0,0 +1,11 @@
{
"entity": {
"switch": {
"other1": { "name": "Otra 1" },
"other2": { "name": "Otra 2" },
"other3": { "name": "Otra 3" },
"other4": { "name": "Otra 4" },
"outlet": { "name": "Enchufe {placeholder}" }
}
}
}