Remove translations from WS get_services and REST /api/services (#147120)

This commit is contained in:
Erik Montnemery
2025-10-23 17:26:33 +02:00
committed by GitHub
parent 1ac2ae3443
commit a020a32d8a
9 changed files with 79 additions and 183 deletions

View File

@@ -58,7 +58,6 @@ from . import (
selector, selector,
target as target_helpers, target as target_helpers,
template, template,
translation,
) )
from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument
from .selector import TargetSelector from .selector import TargetSelector
@@ -586,11 +585,6 @@ async def async_get_all_descriptions(
_load_services_files, integrations _load_services_files, integrations
) )
# Load translations for all service domains
translations = await translation.async_get_translations(
hass, "en", "services", services
)
# Build response # Build response
descriptions: dict[str, dict[str, Any]] = {} descriptions: dict[str, dict[str, Any]] = {}
for domain, services_map in services.items(): for domain, services_map in services.items():
@@ -617,40 +611,11 @@ async def async_get_all_descriptions(
# Don't warn for missing services, because it triggers false # Don't warn for missing services, because it triggers false
# positives for things like scripts, that register as a service # positives for things like scripts, that register as a service
# description = {"fields": yaml_description.get("fields", {})}
# When name & description are in the translations use those;
# otherwise fallback to backwards compatible behavior from
# the time when we didn't have translations for descriptions yet.
# This mimics the behavior of the frontend.
description = {
"name": translations.get(
f"component.{domain}.services.{service_name}.name",
yaml_description.get("name", ""),
),
"description": translations.get(
f"component.{domain}.services.{service_name}.description",
yaml_description.get("description", ""),
),
"fields": dict(yaml_description.get("fields", {})),
}
# Translate fields names & descriptions as well for item in ("description", "name", "target"):
for field_name, field_schema in description["fields"].items(): if item in yaml_description:
if name := translations.get( description[item] = yaml_description[item]
f"component.{domain}.services.{service_name}.fields.{field_name}.name"
):
field_schema["name"] = name
if desc := translations.get(
f"component.{domain}.services.{service_name}.fields.{field_name}.description"
):
field_schema["description"] = desc
if example := translations.get(
f"component.{domain}.services.{service_name}.fields.{field_name}.example"
):
field_schema["example"] = example
if "target" in yaml_description:
description["target"] = yaml_description["target"]
response = service.supports_response response = service.supports_response
if response is not SupportsResponse.NONE: if response is not SupportsResponse.NONE:

View File

@@ -5,18 +5,13 @@
'domain': 'group', 'domain': 'group',
'services': dict({ 'services': dict({
'reload': dict({ 'reload': dict({
'description': 'Reloads group configuration, entities, and notify services from YAML-configuration.',
'fields': dict({ 'fields': dict({
}), }),
'name': 'Reload',
}), }),
'remove': dict({ 'remove': dict({
'description': 'Removes a group.',
'fields': dict({ 'fields': dict({
'object_id': dict({ 'object_id': dict({
'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].',
'example': 'test_group', 'example': 'test_group',
'name': 'Object ID',
'required': True, 'required': True,
'selector': dict({ 'selector': dict({
'object': dict({ 'object': dict({
@@ -25,15 +20,11 @@
}), }),
}), }),
}), }),
'name': 'Remove',
}), }),
'set': dict({ 'set': dict({
'description': 'Creates/Updates a group.',
'fields': dict({ 'fields': dict({
'add_entities': dict({ 'add_entities': dict({
'description': 'List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Add entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -42,17 +33,13 @@
}), }),
}), }),
'all': dict({ 'all': dict({
'description': 'Enable this option if the group should only be used when all entities are in state `on`.',
'name': 'All',
'selector': dict({ 'selector': dict({
'boolean': dict({ 'boolean': dict({
}), }),
}), }),
}), }),
'entities': dict({ 'entities': dict({
'description': 'List of all members in the group. Cannot be used in combination with `Add entities` or `Remove entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -61,18 +48,14 @@
}), }),
}), }),
'icon': dict({ 'icon': dict({
'description': 'Name of the icon for the group.',
'example': 'mdi:camera', 'example': 'mdi:camera',
'name': 'Icon',
'selector': dict({ 'selector': dict({
'icon': dict({ 'icon': dict({
}), }),
}), }),
}), }),
'name': dict({ 'name': dict({
'description': 'Name of the group.',
'example': 'My test group', 'example': 'My test group',
'name': 'Name',
'selector': dict({ 'selector': dict({
'text': dict({ 'text': dict({
'multiline': False, 'multiline': False,
@@ -81,9 +64,7 @@
}), }),
}), }),
'object_id': dict({ 'object_id': dict({
'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].',
'example': 'test_group', 'example': 'test_group',
'name': 'Object ID',
'required': True, 'required': True,
'selector': dict({ 'selector': dict({
'text': dict({ 'text': dict({
@@ -93,9 +74,7 @@
}), }),
}), }),
'remove_entities': dict({ 'remove_entities': dict({
'description': 'List of members to be removed from a group. Cannot be used in combination with `Entities` or `Add entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Remove entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -104,7 +83,6 @@
}), }),
}), }),
}), }),
'name': 'Set',
}), }),
}), }),
}), }),
@@ -139,10 +117,8 @@
'name': 'Translated name', 'name': 'Translated name',
}), }),
'set_level': dict({ 'set_level': dict({
'description': '',
'fields': dict({ 'fields': dict({
}), }),
'name': '',
}), }),
}), }),
}) })

View File

@@ -375,10 +375,6 @@ async def test_api_get_services(
"homeassistant.helpers.service._load_services_file", "homeassistant.helpers.service._load_services_file",
side_effect=_load_services_file, side_effect=_load_services_file,
), ),
patch(
"homeassistant.helpers.service.translation.async_get_translations",
return_value={},
),
): ):
resp = await mock_api_client.get(const.URL_API_SERVICES) resp = await mock_api_client.get(const.URL_API_SERVICES)

View File

@@ -864,7 +864,7 @@ async def test_translated_unit(
"""Test translated unit.""" """Test translated unit."""
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.entity_platform.translation.async_get_translations",
return_value={ return_value={
"component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests"
}, },
@@ -896,7 +896,7 @@ async def test_translated_unit_with_native_unit_raises(
"""Test that translated unit.""" """Test that translated unit."""
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.entity_platform.translation.async_get_translations",
return_value={ return_value={
"component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests"
}, },

View File

@@ -627,9 +627,6 @@ async def test_service_descriptions(hass: HomeAssistant) -> None:
assert descriptions[DOMAIN]["test_name"]["name"] == "ABC" assert descriptions[DOMAIN]["test_name"]["name"] == "ABC"
# Test 4: verify that names from YAML are taken into account as well
assert descriptions[DOMAIN]["turn_on"]["name"] == "Turn on"
async def test_shared_context(hass: HomeAssistant) -> None: async def test_shared_context(hass: HomeAssistant) -> None:
"""Test that the shared context is passed down the chain.""" """Test that the shared context is passed down the chain."""

View File

@@ -601,7 +601,7 @@ async def test_translated_unit(
"""Test translated unit.""" """Test translated unit."""
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.entity_platform.translation.async_get_translations",
return_value={ return_value={
"component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests"
}, },
@@ -633,7 +633,7 @@ async def test_translated_unit_with_native_unit_raises(
"""Test that translated unit.""" """Test that translated unit."""
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.entity_platform.translation.async_get_translations",
return_value={ return_value={
"component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests"
}, },
@@ -664,7 +664,7 @@ async def test_unit_translation_key_without_platform_raises(
"""Test that unit translation key property raises if the entity has no platform yet.""" """Test that unit translation key property raises if the entity has no platform yet."""
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.entity_platform.translation.async_get_translations",
return_value={ return_value={
"component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests"
}, },

View File

@@ -2,18 +2,13 @@
# name: test_get_services # name: test_get_services
dict({ dict({
'reload': dict({ 'reload': dict({
'description': 'Reloads group configuration, entities, and notify services from YAML-configuration.',
'fields': dict({ 'fields': dict({
}), }),
'name': 'Reload',
}), }),
'remove': dict({ 'remove': dict({
'description': 'Removes a group.',
'fields': dict({ 'fields': dict({
'object_id': dict({ 'object_id': dict({
'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].',
'example': 'test_group', 'example': 'test_group',
'name': 'Object ID',
'required': True, 'required': True,
'selector': dict({ 'selector': dict({
'object': dict({ 'object': dict({
@@ -22,15 +17,11 @@
}), }),
}), }),
}), }),
'name': 'Remove',
}), }),
'set': dict({ 'set': dict({
'description': 'Creates/Updates a group.',
'fields': dict({ 'fields': dict({
'add_entities': dict({ 'add_entities': dict({
'description': 'List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Add entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -39,17 +30,13 @@
}), }),
}), }),
'all': dict({ 'all': dict({
'description': 'Enable this option if the group should only be used when all entities are in state `on`.',
'name': 'All',
'selector': dict({ 'selector': dict({
'boolean': dict({ 'boolean': dict({
}), }),
}), }),
}), }),
'entities': dict({ 'entities': dict({
'description': 'List of all members in the group. Cannot be used in combination with `Add entities` or `Remove entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -58,18 +45,14 @@
}), }),
}), }),
'icon': dict({ 'icon': dict({
'description': 'Name of the icon for the group.',
'example': 'mdi:camera', 'example': 'mdi:camera',
'name': 'Icon',
'selector': dict({ 'selector': dict({
'icon': dict({ 'icon': dict({
}), }),
}), }),
}), }),
'name': dict({ 'name': dict({
'description': 'Name of the group.',
'example': 'My test group', 'example': 'My test group',
'name': 'Name',
'selector': dict({ 'selector': dict({
'text': dict({ 'text': dict({
'multiline': False, 'multiline': False,
@@ -78,9 +61,7 @@
}), }),
}), }),
'object_id': dict({ 'object_id': dict({
'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].',
'example': 'test_group', 'example': 'test_group',
'name': 'Object ID',
'required': True, 'required': True,
'selector': dict({ 'selector': dict({
'text': dict({ 'text': dict({
@@ -90,9 +71,7 @@
}), }),
}), }),
'remove_entities': dict({ 'remove_entities': dict({
'description': 'List of members to be removed from a group. Cannot be used in combination with `Entities` or `Add entities`.',
'example': 'domain.entity_id1, domain.entity_id2', 'example': 'domain.entity_id1, domain.entity_id2',
'name': 'Remove entities',
'selector': dict({ 'selector': dict({
'entity': dict({ 'entity': dict({
'multiple': True, 'multiple': True,
@@ -101,7 +80,6 @@
}), }),
}), }),
}), }),
'name': 'Set',
}), }),
}) })
# --- # ---
@@ -132,10 +110,8 @@
'name': 'Translated name', 'name': 'Translated name',
}), }),
'set_level': dict({ 'set_level': dict({
'description': '',
'fields': dict({ 'fields': dict({
}), }),
'name': '',
}), }),
}) })
# --- # ---

View File

@@ -784,10 +784,6 @@ async def test_get_services(
"homeassistant.helpers.service._load_services_file", "homeassistant.helpers.service._load_services_file",
side_effect=_load_services_file, side_effect=_load_services_file,
), ),
patch(
"homeassistant.helpers.service.translation.async_get_translations",
return_value={},
),
): ):
await websocket_client.send_json_auto_id({"type": "get_services"}) await websocket_client.send_json_auto_id({"type": "get_services"})
msg = await websocket_client.receive_json() msg = await websocket_client.receive_json()

View File

@@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable
from copy import deepcopy from copy import deepcopy
import dataclasses import dataclasses
import io import io
import threading
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
@@ -46,14 +47,13 @@ from homeassistant.helpers import (
entity_registry as er, entity_registry as er,
service, service,
) )
from homeassistant.helpers.translation import async_get_translations
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
async_get_integration, async_get_integration,
async_get_integrations, async_get_integrations,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.yaml.loader import parse_yaml from homeassistant.util.yaml.loader import JSON_TYPE, parse_yaml
from tests.common import ( from tests.common import (
MockEntity, MockEntity,
@@ -849,7 +849,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
assert len(descriptions) == 1 assert len(descriptions) == 1
assert DOMAIN_GROUP in descriptions assert DOMAIN_GROUP in descriptions
assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "description" not in descriptions[DOMAIN_GROUP]["reload"]
assert "fields" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"]
# Does not have services # Does not have services
@@ -857,26 +857,39 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
logger_config = {DOMAIN_LOGGER: {}} logger_config = {DOMAIN_LOGGER: {}}
async def async_get_translations( # Test legacy service with translations in services.yaml
hass: HomeAssistant, def _load_services_file(integration: Integration) -> JSON_TYPE:
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
translation_key_prefix = f"component.{DOMAIN_LOGGER}.services.set_default_level"
return { return {
f"{translation_key_prefix}.name": "Translated name", "set_default_level": {
f"{translation_key_prefix}.description": "Translated description", "description": "Translated description",
f"{translation_key_prefix}.fields.level.name": "Field name", "fields": {
f"{translation_key_prefix}.fields.level.description": "Field description", "level": {
f"{translation_key_prefix}.fields.level.example": "Field example", "description": "Field description",
"example": "Field example",
"name": "Field name",
"selector": {
"select": {
"options": [
"debug",
"info",
"warning",
"error",
"fatal",
"critical",
],
"translation_key": "level",
}
},
}
},
"name": "Translated name",
},
"set_level": None,
} }
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.service._load_services_file",
side_effect=async_get_translations, side_effect=_load_services_file,
): ):
await async_setup_component(hass, DOMAIN_LOGGER, logger_config) await async_setup_component(hass, DOMAIN_LOGGER, logger_config)
descriptions = await service.async_get_all_descriptions(hass) descriptions = await service.async_get_all_descriptions(hass)
@@ -1003,18 +1016,11 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None:
assert descriptions == { assert descriptions == {
"test_domain": { "test_domain": {
"test_service": { "test_service": {
"description": "",
"fields": { "fields": {
"test": { "test": {
"selector": { "selector": {"text": {"multiline": False, "multiple": False}}
"text": {
"multiline": False,
"multiple": False,
}
}
} }
}, },
"name": "",
} }
} }
} }
@@ -1096,7 +1102,6 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None:
) )
test_service_schema = { test_service_schema = {
"description": "",
"fields": { "fields": {
"advanced_stuff": { "advanced_stuff": {
"fields": { "fields": {
@@ -1155,7 +1160,6 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None:
}, },
}, },
}, },
"name": "",
"target": { "target": {
"entity": [ "entity": [
{ {
@@ -1191,31 +1195,11 @@ async def test_async_get_all_descriptions_failing_integration(
integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml")
return integrations return integrations
async def wrap_get_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, str]:
translations = await async_get_translations(
hass, language, category, integrations, config_flow
)
return {
key: value
for key, value in translations.items()
if not key.startswith("component.logger.services.")
}
with ( with (
patch( patch(
"homeassistant.helpers.service.async_get_integrations", "homeassistant.helpers.service.async_get_integrations",
wraps=wrap_get_integrations, wraps=wrap_get_integrations,
), ),
patch(
"homeassistant.helpers.service.translation.async_get_translations",
wrap_get_translations,
),
): ):
descriptions = await service.async_get_all_descriptions(hass) descriptions = await service.async_get_all_descriptions(hass)
@@ -1224,16 +1208,12 @@ async def test_async_get_all_descriptions_failing_integration(
# Services are empty defaults if the load fails but should # Services are empty defaults if the load fails but should
# not raise # not raise
assert descriptions[DOMAIN_GROUP]["remove"]["description"] assert "description" not in descriptions[DOMAIN_GROUP]["remove"]
assert descriptions[DOMAIN_GROUP]["remove"]["fields"] assert descriptions[DOMAIN_GROUP]["remove"]["fields"]
assert descriptions[DOMAIN_LOGGER]["set_level"] == { assert descriptions[DOMAIN_LOGGER]["set_level"] == {"fields": {}}
"description": "",
"fields": {},
"name": "",
}
assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] assert "description" not in descriptions[DOMAIN_INPUT_BUTTON]["press"]
assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {}
assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"]
@@ -1288,7 +1268,7 @@ async def test_async_get_all_descriptions_dynamically_created_services(
assert len(descriptions) == 1 assert len(descriptions) == 1
assert "description" in descriptions["group"]["reload"] assert "description" not in descriptions["group"]["reload"]
assert "fields" in descriptions["group"]["reload"] assert "fields" in descriptions["group"]["reload"]
shell_command_config = {DOMAIN_SHELL_COMMAND: {"test_service": "ls /bin"}} shell_command_config = {DOMAIN_SHELL_COMMAND: {"test_service": "ls /bin"}}
@@ -1297,9 +1277,7 @@ async def test_async_get_all_descriptions_dynamically_created_services(
assert len(descriptions) == 2 assert len(descriptions) == 2
assert descriptions[DOMAIN_SHELL_COMMAND]["test_service"] == { assert descriptions[DOMAIN_SHELL_COMMAND]["test_service"] == {
"description": "",
"fields": {}, "fields": {},
"name": "",
"response": {"optional": True}, "response": {"optional": True},
} }
@@ -1314,41 +1292,53 @@ async def test_async_get_all_descriptions_new_service_added_while_loading(
assert len(descriptions) == 1 assert len(descriptions) == 1
assert "description" in descriptions["group"]["reload"] assert "description" not in descriptions["group"]["reload"]
assert "fields" in descriptions["group"]["reload"] assert "fields" in descriptions["group"]["reload"]
logger_domain = DOMAIN_LOGGER logger_domain = DOMAIN_LOGGER
logger_config = {logger_domain: {}} logger_config = {logger_domain: {}}
translations_called = asyncio.Event() translations_called = threading.Event()
translations_wait = asyncio.Event() translations_wait = threading.Event()
async def async_get_translations( def _load_services_file(integration: Integration) -> JSON_TYPE:
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
translations_called.set() translations_called.set()
await translations_wait.wait() translations_wait.wait()
translation_key_prefix = f"component.{logger_domain}.services.set_default_level"
return { return {
f"{translation_key_prefix}.name": "Translated name", "set_default_level": {
f"{translation_key_prefix}.description": "Translated description", "description": "Translated description",
f"{translation_key_prefix}.fields.level.name": "Field name", "fields": {
f"{translation_key_prefix}.fields.level.description": "Field description", "level": {
f"{translation_key_prefix}.fields.level.example": "Field example", "description": "Field description",
"example": "Field example",
"name": "Field name",
"selector": {
"select": {
"options": [
"debug",
"info",
"warning",
"error",
"fatal",
"critical",
],
"translation_key": "level",
}
},
}
},
"name": "Translated name",
},
"set_level": None,
} }
with patch( with patch(
"homeassistant.helpers.service.translation.async_get_translations", "homeassistant.helpers.service._load_services_file",
side_effect=async_get_translations, side_effect=_load_services_file,
): ):
await async_setup_component(hass, logger_domain, logger_config) await async_setup_component(hass, logger_domain, logger_config)
task = asyncio.create_task(service.async_get_all_descriptions(hass)) task = asyncio.create_task(service.async_get_all_descriptions(hass))
await translations_called.wait() await hass.async_add_executor_job(translations_called.wait)
# Now register a new service while translations are being loaded # Now register a new service while translations are being loaded
hass.services.async_register(logger_domain, "new_service", lambda x: None, None) hass.services.async_register(logger_domain, "new_service", lambda x: None, None)
service.async_set_service_schema( service.async_set_service_schema(