Base entity ids on English for languages not using Latin script (#91357)

This commit is contained in:
Erik Montnemery 2023-06-27 14:37:50 +02:00 committed by GitHub
parent fe28067481
commit 071d3a474f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 350 additions and 26 deletions

View File

@ -3,6 +3,8 @@
To update, run python3 -m script.languages [frontend_tag] To update, run python3 -m script.languages [frontend_tag]
""" """
DEFAULT_LANGUAGE = "en"
LANGUAGES = { LANGUAGES = {
"af", "af",
"ar", "ar",
@ -66,3 +68,46 @@ LANGUAGES = {
"zh-Hans", "zh-Hans",
"zh-Hant", "zh-Hant",
} }
NATIVE_ENTITY_IDS = {
"af",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"en",
"en-GB",
"eo",
"es",
"es-419",
"et",
"eu",
"fi",
"fr",
"fy",
"gl",
"gsw",
"hr",
"hu",
"id",
"is",
"it",
"ka",
"lb",
"lt",
"lv",
"nb",
"nl",
"nn",
"pl",
"pt",
"pt-BR",
"ro",
"sk",
"sl",
"sr-Latn",
"sv",
"tr",
}

View File

@ -381,17 +381,31 @@ class Entity(ABC):
return self.entity_description.has_entity_name return self.entity_description.has_entity_name
return False return False
@cached_property def _device_class_name_helper(
def _device_class_name(self) -> str | None: self,
component_translations: dict[str, Any],
) -> str | None:
"""Return a translated name of the entity based on its device class.""" """Return a translated name of the entity based on its device class."""
if not self.has_entity_name: if not self.has_entity_name:
return None return None
device_class_key = self.device_class or "_" device_class_key = self.device_class or "_"
platform = self.platform platform = self.platform
name_translation_key = ( name_translation_key = (
f"component.{platform.domain}.entity_component." f"{device_class_key}.name" f"component.{platform.domain}.entity_component.{device_class_key}.name"
) )
return platform.component_translations.get(name_translation_key) return component_translations.get(name_translation_key)
@cached_property
def _object_id_device_class_name(self) -> str | None:
"""Return a translated name of the entity based on its device class."""
return self._device_class_name_helper(
self.platform.object_id_component_translations
)
@cached_property
def _device_class_name(self) -> str | None:
"""Return a translated name of the entity based on its device class."""
return self._device_class_name_helper(self.platform.component_translations)
def _default_to_device_class_name(self) -> bool: def _default_to_device_class_name(self) -> bool:
"""Return True if an unnamed entity should be named by its device class.""" """Return True if an unnamed entity should be named by its device class."""
@ -408,15 +422,18 @@ class Entity(ABC):
f".{self.translation_key}.name" f".{self.translation_key}.name"
) )
@property def _name_internal(
def name(self) -> str | UndefinedType | None: self,
device_class_name: str | None,
platform_translations: dict[str, Any],
) -> str | UndefinedType | None:
"""Return the name of the entity.""" """Return the name of the entity."""
if hasattr(self, "_attr_name"): if hasattr(self, "_attr_name"):
return self._attr_name return self._attr_name
if ( if (
self.has_entity_name self.has_entity_name
and (name_translation_key := self._name_translation_key) and (name_translation_key := self._name_translation_key)
and (name := self.platform.platform_translations.get(name_translation_key)) and (name := platform_translations.get(name_translation_key))
): ):
if TYPE_CHECKING: if TYPE_CHECKING:
assert isinstance(name, str) assert isinstance(name, str)
@ -424,15 +441,42 @@ class Entity(ABC):
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():
return self._device_class_name return device_class_name
return description_name return description_name
# The entity has no name set by _attr_name, translation_key or entity_description # The entity has no name set by _attr_name, translation_key or entity_description
# Check if the entity should be named by its device class # Check if the entity should be named by its device class
if self._default_to_device_class_name(): if self._default_to_device_class_name():
return self._device_class_name return device_class_name
return UNDEFINED return UNDEFINED
@property
def suggested_object_id(self) -> str | None:
"""Return input for object id."""
# The check for self.platform guards against integrations not using an
# EntityComponent and can be removed in HA Core 2024.1
# mypy doesn't know about fget: https://github.com/python/mypy/issues/6185
if self.__class__.name.fget is Entity.name.fget and self.platform: # type: ignore[attr-defined]
name = self._name_internal(
self._object_id_device_class_name,
self.platform.object_id_platform_translations,
)
else:
name = self.name
return None if name is UNDEFINED else name
@property
def name(self) -> str | UndefinedType | None:
"""Return the name of the entity."""
# The check for self.platform guards against integrations not using an
# EntityComponent and can be removed in HA Core 2024.1
if not self.platform:
return self._name_internal(None, {})
return self._name_internal(
self._device_class_name,
self.platform.platform_translations,
)
@property @property
def state(self) -> StateType: def state(self) -> StateType:
"""Return the state of the entity.""" """Return the state of the entity."""

View File

@ -31,6 +31,7 @@ from homeassistant.exceptions import (
PlatformNotReady, PlatformNotReady,
RequiredParameterMissing, RequiredParameterMissing,
) )
from homeassistant.generated import languages
from homeassistant.setup import async_start_setup from homeassistant.setup import async_start_setup
from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.async_ import run_callback_threadsafe
@ -128,6 +129,8 @@ class EntityPlatform:
self.entities: dict[str, Entity] = {} self.entities: dict[str, Entity] = {}
self.component_translations: dict[str, Any] = {} self.component_translations: dict[str, Any] = {}
self.platform_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {}
self.object_id_component_translations: dict[str, Any] = {}
self.object_id_platform_translations: dict[str, Any] = {}
self._tasks: list[asyncio.Task[None]] = [] self._tasks: list[asyncio.Task[None]] = []
# Stop tracking tasks after setup is completed # Stop tracking tasks after setup is completed
self._setup_complete = False self._setup_complete = False
@ -294,22 +297,43 @@ class EntityPlatform:
logger = self.logger logger = self.logger
hass = self.hass hass = self.hass
full_name = f"{self.domain}.{self.platform_name}" full_name = f"{self.domain}.{self.platform_name}"
object_id_language = (
hass.config.language
if hass.config.language in languages.NATIVE_ENTITY_IDS
else languages.DEFAULT_LANGUAGE
)
try: async def get_translations(
self.component_translations = await translation.async_get_translations( language: str, category: str, integration: str
hass, hass.config.language, "entity_component", {self.domain} ) -> dict[str, Any]:
"""Get entity translations."""
try:
return await translation.async_get_translations(
hass, language, category, {integration}
)
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.debug(
"Could not load translations for %s",
integration,
exc_info=err,
)
return {}
self.component_translations = await get_translations(
hass.config.language, "entity_component", self.domain
)
self.platform_translations = await get_translations(
hass.config.language, "entity", self.platform_name
)
if object_id_language == hass.config.language:
self.object_id_component_translations = self.component_translations
self.object_id_platform_translations = self.platform_translations
else:
self.object_id_component_translations = await get_translations(
object_id_language, "entity_component", self.domain
) )
except Exception as err: # pylint: disable=broad-exception-caught self.object_id_platform_translations = await get_translations(
_LOGGER.debug( object_id_language, "entity", self.platform_name
"Could not load translations for %s", self.domain, exc_info=err
)
try:
self.platform_translations = await translation.async_get_translations(
hass, hass.config.language, "entity", {self.platform_name}
)
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.debug(
"Could not load translations for %s", self.platform_name, exc_info=err
) )
logger.info("Setting up %s", full_name) logger.info("Setting up %s", full_name)
@ -652,9 +676,11 @@ class EntityPlatform:
if entity.use_device_name: if entity.use_device_name:
suggested_object_id = device_name suggested_object_id = device_name
else: else:
suggested_object_id = f"{device_name} {entity_name}" suggested_object_id = (
f"{device_name} {entity.suggested_object_id}"
)
if not suggested_object_id: if not suggested_object_id:
suggested_object_id = entity_name suggested_object_id = entity.suggested_object_id
if self.entity_namespace is not None: if self.entity_namespace is not None:
suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" suggested_object_id = f"{self.entity_namespace} {suggested_object_id}"
@ -709,7 +735,7 @@ class EntityPlatform:
# Generate entity ID # Generate entity ID
if entity.entity_id is None or generate_new_entity_id: if entity.entity_id is None or generate_new_entity_id:
suggested_object_id = ( suggested_object_id = (
suggested_object_id or entity_name or DEVICE_DEFAULT_NAME suggested_object_id or entity.suggested_object_id or DEVICE_DEFAULT_NAME
) )
if self.entity_namespace is not None: if self.entity_namespace is not None:

View File

@ -15,10 +15,61 @@ req = requests.get(
data = json.loads(req.content) data = json.loads(req.content)
languages = set(data.keys()) languages = set(data.keys())
# Languages which can be used for entity IDs.
# Languages in the set are those which use a writing system based on the Latin
# script. Languages not in this set will instead base the entity ID on English.
# Note: Although vietnamese writing is based on the Latin script, it's too ambiguous
# after accents and diacritics have been removed by slugify
NATIVE_ENTITY_IDS = {
"af", # Afrikaans
"bs", # Bosanski
"ca", # Català
"cs", # Čeština
"cy", # Cymraeg
"da", # Dansk
"de", # Deutsch
"en", # English
"en-GB", # English (GB)
"eo", # Esperanto
"es", # Español
"es-419", # Español (Latin America)
"et", # Eesti
"eu", # Euskara
"fi", # Suomi
"fr", # Français
"fy", # Frysk
"gl", # Galego
"gsw", # Schwiizerdütsch
"hr", # Hrvatski
"hu", # Magyar
"id", # Indonesia
"is", # Íslenska
"it", # Italiano
"ka", # Kartuli
"lb", # Lëtzebuergesch
"lt", # Lietuvių
"lv", # Latviešu
"nb", # Nederlands
"nl", # Norsk Bokmål
"nn", # Norsk Nynorsk"
"pl", # Polski
"pt", # Português
"pt-BR", # Português (BR)
"ro", # Română
"sk", # Slovenčina
"sl", # Slovenščina
"sr-Latn", # Srpski
"sv", # Svenska
"tr", # Türkçe
}
Path("homeassistant/generated/languages.py").write_text( Path("homeassistant/generated/languages.py").write_text(
format_python_namespace( format_python_namespace(
{ {
"DEFAULT_LANGUAGE": "en",
"LANGUAGES": languages, "LANGUAGES": languages,
"NATIVE_ENTITY_IDS": NATIVE_ENTITY_IDS,
}, },
generator="script.languages [frontend_tag]", generator="script.languages [frontend_tag]",
) )

View File

@ -1,5 +1,6 @@
"""Tests for the EntityPlatform helper.""" """Tests for the EntityPlatform helper."""
import asyncio import asyncio
from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@ -18,6 +19,7 @@ from homeassistant.helpers import (
) )
from homeassistant.helpers.entity import ( from homeassistant.helpers.entity import (
DeviceInfo, DeviceInfo,
Entity,
EntityCategory, EntityCategory,
async_generate_entity_id, async_generate_entity_id,
) )
@ -1669,3 +1671,159 @@ async def test_entity_name_influences_entity_id(
assert len(hass.states.async_entity_ids()) == 1 assert len(hass.states.async_entity_ids()) == 1
assert registry.async_get(expected_entity_id) is not None assert registry.async_get(expected_entity_id) is not None
@pytest.mark.parametrize(
("language", "has_entity_name", "expected_entity_id"),
(
("en", False, "test_domain.test_qwer"), # Set to <platform>_<unique_id>
("en", True, "test_domain.device_bla_english_name"),
("sv", True, "test_domain.device_bla_swedish_name"),
# Chinese uses english for entity_id
("cn", True, "test_domain.device_bla_english_name"),
),
)
async def test_translated_entity_name_influences_entity_id(
hass: HomeAssistant,
language: str,
has_entity_name: bool,
expected_entity_id: str,
) -> None:
"""Test entity_id is influenced by translated entity name."""
class TranslatedEntity(Entity):
_attr_unique_id = "qwer"
_attr_device_info = {
"identifiers": {("hue", "1234")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")},
"name": "Device Bla",
}
_attr_translation_key = "test"
def __init__(self, has_entity_name: bool) -> None:
"""Initialize."""
self._attr_has_entity_name = has_entity_name
registry = er.async_get(hass)
translations = {
"en": {"component.test.entity.test_domain.test.name": "English name"},
"sv": {"component.test.entity.test_domain.test.name": "Swedish name"},
"cn": {"component.test.entity.test_domain.test.name": "Chinese name"},
}
hass.config.language = language
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([TranslatedEntity(has_entity_name)])
return True
platform = MockPlatform(async_setup_entry=async_setup_entry)
config_entry = MockConfigEntry(entry_id="super-mock-id")
entity_platform = MockEntityPlatform(
hass, platform_name=config_entry.domain, platform=platform
)
with patch(
"homeassistant.helpers.entity_platform.translation.async_get_translations",
side_effect=async_get_translations,
):
assert await entity_platform.async_setup_entry(config_entry)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids()) == 1
assert registry.async_get(expected_entity_id) is not None
@pytest.mark.parametrize(
("language", "has_entity_name", "device_class", "expected_entity_id"),
(
("en", False, None, "test_domain.test_qwer"), # Set to <platform>_<unique_id>
(
"en",
False,
"test_class",
"test_domain.test_qwer",
), # Set to <platform>_<unique_id>
("en", True, "test_class", "test_domain.device_bla_english_cls"),
("sv", True, "test_class", "test_domain.device_bla_swedish_cls"),
# Chinese uses english for entity_id
("cn", True, "test_class", "test_domain.device_bla_english_cls"),
),
)
async def test_translated_device_class_name_influences_entity_id(
hass: HomeAssistant,
language: str,
has_entity_name: bool,
device_class: str | None,
expected_entity_id: str,
) -> None:
"""Test entity_id is influenced by translated entity name."""
class TranslatedDeviceClassEntity(Entity):
_attr_unique_id = "qwer"
_attr_device_info = {
"identifiers": {("hue", "1234")},
"connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")},
"name": "Device Bla",
}
def __init__(self, device_class: str | None, has_entity_name: bool) -> None:
"""Initialize."""
self._attr_device_class = device_class
self._attr_has_entity_name = has_entity_name
def _default_to_device_class_name(self) -> bool:
"""Return True if an unnamed entity should be named by its device class."""
return self.device_class is not None
registry = er.async_get(hass)
translations = {
"en": {"component.test_domain.entity_component.test_class.name": "English cls"},
"sv": {"component.test_domain.entity_component.test_class.name": "Swedish cls"},
"cn": {"component.test_domain.entity_component.test_class.name": "Chinese cls"},
}
hass.config.language = language
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([TranslatedDeviceClassEntity(device_class, has_entity_name)])
return True
platform = MockPlatform(async_setup_entry=async_setup_entry)
config_entry = MockConfigEntry(entry_id="super-mock-id")
entity_platform = MockEntityPlatform(
hass, platform_name=config_entry.domain, platform=platform
)
with patch(
"homeassistant.helpers.entity_platform.translation.async_get_translations",
side_effect=async_get_translations,
):
assert await entity_platform.async_setup_entry(config_entry)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids()) == 1
assert registry.async_get(expected_entity_id) is not None