From 10299b2ef4041ae6abcfffae0a7140533dbb8ea6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Nov 2025 09:54:22 +0100 Subject: [PATCH] Add description placeholders to service translation strings (#154984) Co-authored-by: Erik Montnemery --- .../components/kitchen_sink/__init__.py | 17 ++- .../components/kitchen_sink/strings.json | 10 +- homeassistant/core.py | 23 +++- homeassistant/helpers/entity_component.py | 5 +- homeassistant/helpers/entity_platform.py | 4 +- homeassistant/helpers/service.py | 11 +- tests/helpers/test_entity_component.py | 14 +- tests/helpers/test_entity_platform.py | 17 ++- tests/helpers/test_service.py | 128 ++++++++++++++++++ 9 files changed, 212 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index b72e1df1a6e..15f7314ee7a 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.issue_registry import ( @@ -81,11 +81,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) @callback - def service_handler(call: ServiceCall | None = None) -> None: + def service_handler(call: ServiceCall | None = None) -> ServiceResponse: """Do nothing.""" + return None hass.services.async_register( - DOMAIN, "test_service_1", service_handler, SCHEMA_SERVICE_TEST_SERVICE_1 + DOMAIN, + "test_service_1", + service_handler, + SCHEMA_SERVICE_TEST_SERVICE_1, + description_placeholders={ + "meep_1": "foo", + "meep_2": "bar", + "meep_3": "beer", + "meep_4": "milk", + "meep_5": "https://example.com", + }, ) return True diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index 0b816675cfc..c5a49a48e8f 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -117,14 +117,16 @@ }, "services": { "test_service_1": { - "description": "Fake action for testing", + "description": "Fake action for testing {meep_2}", "fields": { "field_1": { - "description": "Number of seconds", - "name": "Field 1" + "description": "Number of seconds {meep_4}", + "example": "Example: {meep_5}", + "name": "Field 1 {meep_3}" }, "field_2": { "description": "Mode", + "example": "Field 2 example", "name": "Field 2" }, "field_3": { @@ -136,7 +138,7 @@ "name": "Field 4" } }, - "name": "Test action 1", + "name": "Test action {meep_1}", "sections": { "advanced_fields": { "description": "Some very advanced things", diff --git a/homeassistant/core.py b/homeassistant/core.py index ca2551bb5c4..584616277d4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2426,7 +2426,14 @@ class SupportsResponse(enum.StrEnum): class Service: """Representation of a callable service.""" - __slots__ = ["domain", "job", "schema", "service", "supports_response"] + __slots__ = [ + "description_placeholders", + "domain", + "job", + "schema", + "service", + "supports_response", + ] def __init__( self, @@ -2443,11 +2450,13 @@ class Service: context: Context | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, job_type: HassJobType | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Initialize a service.""" self.job = HassJob(func, f"service {domain}.{service}", job_type=job_type) self.schema = schema self.supports_response = supports_response + self.description_placeholders = description_placeholders class ServiceCall: @@ -2590,6 +2599,8 @@ class ServiceRegistry: schema: VolSchemaType | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, job_type: HassJobType | None = None, + *, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Register a service. @@ -2599,7 +2610,13 @@ class ServiceRegistry: """ self._hass.verify_event_loop_thread("hass.services.async_register") self._async_register( - domain, service, service_func, schema, supports_response, job_type + domain, + service, + service_func, + schema, + supports_response, + job_type, + description_placeholders, ) @callback @@ -2617,6 +2634,7 @@ class ServiceRegistry: schema: VolSchemaType | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, job_type: HassJobType | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Register a service. @@ -2633,6 +2651,7 @@ class ServiceRegistry: service, supports_response=supports_response, job_type=job_type, + description_placeholders=description_placeholders, ) if domain in self._services: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2baeb31bdc8..7fbeaad28f4 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from datetime import timedelta import logging from types import ModuleType @@ -251,6 +251,8 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: func: str | Callable[..., Any], required_features: list[int] | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, + *, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Register an entity service.""" service.async_register_entity_service( @@ -263,6 +265,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: required_features=required_features, schema=schema, supports_response=supports_response, + description_placeholders=description_placeholders, ) async def async_setup_platform( diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0a676351ee0..cc4bc7330ec 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Iterable +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from contextvars import ContextVar from datetime import timedelta from logging import Logger, getLogger @@ -1081,6 +1081,7 @@ class EntityPlatform: supports_response: SupportsResponse = SupportsResponse.NONE, *, entity_device_classes: Iterable[str | None] | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Register an entity service. @@ -1100,6 +1101,7 @@ class EntityPlatform: required_features=required_features, schema=schema, supports_response=supports_response, + description_placeholders=description_placeholders, ) async def _async_update_entity_states(self) -> None: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 25e68ae747d..8b28df0f19a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Iterable +from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses from enum import Enum from functools import cache, partial @@ -612,6 +612,8 @@ async def async_get_all_descriptions( # Don't warn for missing services, because it triggers false # positives for things like scripts, that register as a service description = {"fields": yaml_description.get("fields", {})} + if description_placeholders := service.description_placeholders: + description["description_placeholders"] = description_placeholders for item in ("description", "name", "target"): if item in yaml_description: @@ -955,6 +957,8 @@ def async_register_admin_service( ], schema: VolSchemaType = vol.Schema({}, extra=vol.PREVENT_EXTRA), supports_response: SupportsResponse = SupportsResponse.NONE, + *, + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Register a service that requires admin access.""" hass.services.async_register( @@ -967,6 +971,7 @@ def async_register_admin_service( ), schema, supports_response, + description_placeholders=description_placeholders, ) @@ -1112,6 +1117,7 @@ def async_register_entity_service( domain: str, name: str, *, + description_placeholders: Mapping[str, str] | None = None, entity_device_classes: Iterable[str | None] | None = None, entities: dict[str, Entity], func: str | Callable[..., Any], @@ -1145,6 +1151,7 @@ def async_register_entity_service( schema, supports_response, job_type=job_type, + description_placeholders=description_placeholders, ) @@ -1154,6 +1161,7 @@ def async_register_platform_entity_service( service_domain: str, service_name: str, *, + description_placeholders: Mapping[str, str] | None = None, entity_device_classes: Iterable[str | None] | None = None, entity_domain: str, func: str | Callable[..., Any], @@ -1191,4 +1199,5 @@ def async_register_platform_entity_service( schema, supports_response, job_type=HassJobType.Coroutinefunction, + description_placeholders=description_placeholders, ) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index dc24e715620..6cdbcc8bb60 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_component import EntityComponent, async_update_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -511,7 +512,7 @@ async def test_register_entity_service( schema: dict | None, service_data: dict, ) -> None: - """Test registering an enttiy service and calling it.""" + """Test registering an entity service and calling it.""" entity = MockEntity(entity_id=f"{DOMAIN}.entity") calls = [] @@ -525,7 +526,16 @@ async def test_register_entity_service( await component.async_setup({}) await component.async_add_entities([entity]) - component.async_register_entity_service("hello", schema, "async_called_by_service") + component.async_register_entity_service( + "hello", + schema, + "async_called_by_service", + description_placeholders={"test_placeholder": "beer"}, + ) + descriptions = await async_get_all_descriptions(hass) + assert descriptions["test_domain"]["hello"]["description_placeholders"] == { + "test_placeholder": "beer" + } with pytest.raises(vol.Invalid): await hass.services.async_call( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 9f4b6a83c80..a3e9df4616b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -40,6 +40,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -1639,10 +1640,20 @@ async def test_platforms_sharing_services(hass: HomeAssistant) -> None: def handle_service(entity, data): entities.append(entity) - entity_platform1.async_register_entity_service("hello", {}, handle_service) - entity_platform2.async_register_entity_service( - "hello", {}, Mock(side_effect=AssertionError("Should not be called")) + entity_platform1.async_register_entity_service( + "hello", {}, handle_service, description_placeholders={"drink": "beer"} ) + entity_platform2.async_register_entity_service( + "hello", + {}, + Mock(side_effect=AssertionError("Should not be called")), + description_placeholders={"drink": "milk"}, + ) + + descriptions = await async_get_all_descriptions(hass) + assert descriptions["mock_platform"]["hello"]["description_placeholders"] == { + "drink": "beer" + } await hass.services.async_call( "mock_platform", "hello", {"entity_id": "all"}, blocking=True diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 4f000ad7398..59e9f957eb0 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1397,6 +1397,96 @@ async def test_async_get_all_descriptions_new_service_added_while_loading( assert descriptions[logger_domain]["new_service"]["description"] == "new service" +async def test_async_get_descriptions_with_placeholders(hass: HomeAssistant) -> None: + """Test descriptions async_get_all_descriptions with placeholders. + + Placeholders supplied with a service registration should be included. + """ + service_descriptions = """ + happy_time: + fields: + topic: + selector: + text: + duration: + default: 5 + selector: + number: + min: 1 + max: 300 + unit_of_measurement: "seconds" + """ + + service_schema = vol.Schema( + { + "topic": cv.string, + "duration": cv.positive_int, + } + ) + + domain = "test_domain" + + hass.services.async_register( + domain, + "happy_time", + lambda call: None, + schema=service_schema, + description_placeholders={"placeholder": "beer"}, + ) + mock_integration(hass, MockModule(domain), top_level_files={"services.yaml"}) + assert await async_setup_component(hass, domain, {}) + + def load_yaml(fname, secrets=None): + with io.StringIO(service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.service._load_services_files", + side_effect=service._load_services_files, + ) as proxy_load_services_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=load_yaml, + ) as mock_load_yaml, + ): + descriptions = await service.async_get_all_descriptions(hass) + + mock_load_yaml.assert_called_once_with( + "homeassistant/components/test_domain/services.yaml", None + ) + assert proxy_load_services_files.mock_calls[0][1][0] == unordered( + [ + await async_get_integration(hass, domain), + ] + ) + + assert descriptions == { + "test_domain": { + "happy_time": { + "fields": { + "topic": { + "selector": {"text": {"multiple": False, "multiline": False}} + }, + "duration": { + "default": 5, + "selector": { + "number": { + "min": 1.0, + "max": 300.0, + "unit_of_measurement": "seconds", + "step": 1.0, + "mode": "slider", + } + }, + }, + }, + "description_placeholders": {"placeholder": "beer"}, + } + } + } + + async def test_register_with_mixed_case(hass: HomeAssistant) -> None: """Test registering a service with mixed case. @@ -1842,6 +1932,37 @@ async def test_register_admin_service( assert calls[0].context.user_id == hass_admin_user.id +async def test_register_admin_service_with_placeholders( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test the register admin service with description placeholders.""" + calls = [] + + async def mock_service(call): + calls.append(call) + + service.async_register_admin_service( + hass, + "test", + "test", + mock_service, + description_placeholders={"test_placeholder": "beer"}, + ) + await hass.services.async_call( + "test", + "test", + {}, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + assert len(calls) == 1 + + descriptions = await service.async_get_all_descriptions(hass) + assert descriptions["test"]["test"]["description_placeholders"] == { + "test_placeholder": "beer" + } + + @pytest.mark.parametrize( "supports_response", [SupportsResponse.ONLY, SupportsResponse.OPTIONAL], @@ -2651,6 +2772,13 @@ async def test_register_platform_entity_service( entity_domain="mock_integration", schema={}, func=handle_service, + description_placeholders={"test_placeholder": "beer"}, + ) + descriptions = await service.async_get_all_descriptions(hass) + assert ( + descriptions["mock_platform"]["hello"]["description_placeholders"] + == {"test_placeholder": "beer"} + == {"test_placeholder": "beer"} ) await hass.services.async_call(