diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 9dce352df30..7b7f8225063 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -162,12 +162,12 @@ def setup_service_functions( It appears that all TCC-compatible systems support the same three zones modes. """ - @verify_domain_control(hass, DOMAIN) + @verify_domain_control(DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" await coordinator.async_refresh() - @verify_domain_control(hass, DOMAIN) + @verify_domain_control(DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the system mode.""" assert coordinator.tcs is not None # mypy @@ -179,7 +179,7 @@ def setup_service_functions( } async_dispatcher_send(hass, DOMAIN, payload) - @verify_domain_control(hass, DOMAIN) + @verify_domain_control(DOMAIN) async def set_zone_override(call: ServiceCall) -> None: """Set the zone override (setpoint).""" entity_id = call.data[ATTR_ENTITY_ID] diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 9ca6ecfcfe0..9bc645c6391 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -124,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> def setup_service_functions(hass: HomeAssistant, broker): """Set up the service functions.""" - @verify_domain_control(hass, DOMAIN) + @verify_domain_control(DOMAIN) async def set_zone_mode(call: ServiceCall) -> None: """Set the system mode.""" entity_id = call.data[ATTR_ENTITY_ID] diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 1cfb3a55552..9e663ae5490 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -124,7 +124,7 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema( def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" - @verify_domain_control(hass, DOMAIN) + @verify_domain_control(DOMAIN) async def async_call_hmipc_service(service: ServiceCall) -> None: """Call correct HomematicIP Cloud service.""" service_name = service.service diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 0fd6e8bdae0..1a70e98e5b3 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -64,7 +64,7 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_HUE_ACTIVATE_SCENE, - verify_domain_control(hass, DOMAIN)(hue_activate_scene), + verify_domain_control(DOMAIN)(hue_activate_scene), schema=vol.Schema( { vol.Required(ATTR_GROUP_NAME): cv.string, diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 9d678c16874..734dbecd88b 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -89,7 +89,7 @@ async def async_setup_entry( elif service_call.service == SERVICE_RESTORE: entity.restore() - @service.verify_domain_control(hass, DOMAIN) + @service.verify_domain_control(DOMAIN) async def async_service_handle(service_call: core.ServiceCall) -> None: """Handle for services.""" entities = await platform.async_extract_from_service(service_call) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 67bf94c61ae..f2ef3ce9063 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -290,7 +290,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SimpliSafe as config entry.""" _async_standardize_config_entry(hass, entry) - _verify_domain_control = verify_domain_control(hass, DOMAIN) + _verify_domain_control = verify_domain_control(DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) try: diff --git a/homeassistant/components/sonos/services.py b/homeassistant/components/sonos/services.py index 1f2daee5698..e2ec1bffdce 100644 --- a/homeassistant/components/sonos/services.py +++ b/homeassistant/components/sonos/services.py @@ -35,7 +35,7 @@ ATTR_WITH_GROUP = "with_group" def async_setup_services(hass: HomeAssistant) -> None: """Register Sonos services.""" - @service.verify_domain_control(hass, DOMAIN) + @service.verify_domain_control(DOMAIN) async def async_service_handle(service_call: ServiceCall) -> None: """Handle dispatched services.""" platform_entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get( diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 29d9237de05..6dfb002305a 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -138,6 +138,41 @@ def deprecated_function[**_P, _R]( return deprecated_decorator +def deprecated_hass_argument[**_P, _T]( + breaks_in_ha_version: str | None = None, +) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: + """Decorate function to indicate that first argument hass will be ignored.""" + + def _decorator(func: Callable[_P, _T]) -> Callable[_P, _T]: + @functools.wraps(func) + def _inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: + from homeassistant.core import HomeAssistant # noqa: PLC0415 + + in_arg = len(args) > 0 and isinstance(args[0], HomeAssistant) + in_kwarg = "hass" in kwargs and isinstance(kwargs["hass"], HomeAssistant) + + if in_arg or in_kwarg: + _print_deprecation_warning_internal( + "hass", + func.__module__, + f"{func.__name__} without hass argument", + "argument", + f"passed to {func.__name__}", + breaks_in_ha_version, + log_when_no_integration_is_found=True, + ) + if in_arg: + args = args[1:] # type: ignore[assignment] + if in_kwarg: + kwargs.pop("hass") + + return func(*args, **kwargs) + + return _inner + + return _decorator + + def _print_deprecation_warning( obj: Any, replacement: str, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3b4bafeded7..734d2a4dfa0 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -60,7 +60,7 @@ from . import ( template, translation, ) -from .deprecation import deprecated_class, deprecated_function +from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -995,10 +995,10 @@ def async_register_admin_service( ) -@bind_hass +@deprecated_hass_argument(breaks_in_ha_version="2026.10") @callback def verify_domain_control( - hass: HomeAssistant, domain: str + domain: str, ) -> Callable[[Callable[[ServiceCall], Any]], Callable[[ServiceCall], Any]]: """Ensure permission to access any entity under domain in service call.""" @@ -1014,6 +1014,7 @@ def verify_domain_control( if not call.context.user_id: return await service_handler(call) + hass = call.hass user = await hass.auth.async_get_user(call.context.user_id) if user is None: diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index d45c9ce1546..b77e7e1ef44 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -17,6 +17,7 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, deprecated_class, deprecated_function, + deprecated_hass_argument, deprecated_substitute, dir_with_deprecated_constants, get_deprecated, @@ -638,3 +639,77 @@ def test_enum_with_deprecated_members_integration_not_found( TestEnum.DOGS # noqa: B018 assert len(caplog.record_tuples) == 0 + + +@pytest.mark.parametrize( + ("positional_arguments", "keyword_arguments"), + [ + # without kwargs + ([], {}), + (["first_arg"], {}), + (["first_arg", "second_arg"], {}), + # with single kwargs + ([], {"first_kwarg": "first_value"}), + (["first_arg"], {"first_kwarg": "first_value"}), + (["first_arg", "second_arg"], {"first_kwarg": "first_value"}), + # with double kwargs + ([], {"first_kwarg": "first_value", "second_kwarg": "second_value"}), + (["first_arg"], {"first_kwarg": "first_value", "second_kwarg": "second_value"}), + ( + ["first_arg", "second_arg"], + {"first_kwarg": "first_value", "second_kwarg": "second_value"}, + ), + ], +) +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " It will be removed in HA Core 2099.1."), + ], +) +def test_deprecated_hass_argument( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + positional_arguments: list[str], + keyword_arguments: dict[str, str], + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: + """Test deprecated_hass_argument decorator.""" + + calls = [] + + @deprecated_hass_argument(breaks_in_ha_version=breaks_in_ha_version) + def mock_deprecated_function(*args: str, **kwargs: str) -> None: + calls.append((args, kwargs)) + + mock_deprecated_function(*positional_arguments, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) not in caplog.text + assert len(calls) == 1 + + mock_deprecated_function(hass, *positional_arguments, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) in caplog.text + assert len(calls) == 2 + + caplog.clear() + mock_deprecated_function(*positional_arguments, hass=hass, **keyword_arguments) + assert ( + "The deprecated argument hass was passed to mock_deprecated_function." + f"{extra_msg}" + " Use mock_deprecated_function without hass argument instead" + ) in caplog.text + assert len(calls) == 3 + + # Ensure that the two calls are the same, as the second call should have been + # modified to remove the hass argument. + assert calls[0] == calls[1] + assert calls[0] == calls[2] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index da4cdec4a0a..8a1329c21bf 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,7 +1,7 @@ """Test service helpers.""" import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from copy import deepcopy import dataclasses import io @@ -1785,7 +1785,28 @@ async def test_register_admin_service_return_response( assert result == {"test-reply": "test-value1"} -async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> None: +_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE = ( + "The deprecated argument hass was passed to verify_domain_control. It will be" + " removed in HA Core 2026.10. Use verify_domain_control without hass argument" + " instead" +) + + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_not_async( + hass: HomeAssistant, + mock_entities, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -1794,10 +1815,26 @@ async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> N calls.append(call) with pytest.raises(exceptions.HomeAssistantError): - service.verify_domain_control(hass, "test_domain")(mock_service_log) + decorator(hass, "test_domain")(mock_service_log) + + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog -async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> None: +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_unknown( + hass: HomeAssistant, + mock_entities, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -1809,9 +1846,7 @@ async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> Non "homeassistant.helpers.entity_registry.async_get", return_value=Mock(entities=mock_entities), ): - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1827,9 +1862,23 @@ async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> Non ) assert len(calls) == 0 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) async def test_domain_control_unauthorized( - hass: HomeAssistant, hass_read_only_user: MockUser + hass: HomeAssistant, + hass_read_only_user: MockUser, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test domain verification in a service call with an unauthorized user.""" mock_registry( @@ -1849,9 +1898,7 @@ async def test_domain_control_unauthorized( """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1868,9 +1915,23 @@ async def test_domain_control_unauthorized( assert len(calls) == 0 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) async def test_domain_control_admin( - hass: HomeAssistant, hass_admin_user: MockUser + hass: HomeAssistant, + hass_admin_user: MockUser, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test domain verification in a service call with an admin user.""" mock_registry( @@ -1890,9 +1951,7 @@ async def test_domain_control_admin( """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1908,8 +1967,23 @@ async def test_domain_control_admin( assert len(calls) == 1 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog -async def test_domain_control_no_user(hass: HomeAssistant) -> None: + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # old pass-through + (lambda _, domain: service.verify_domain_control(domain), False), # new + ], +) +async def test_domain_control_no_user( + hass: HomeAssistant, + decorator: Callable[[HomeAssistant, str], Any], + in_caplog: bool, + caplog: pytest.LogCaptureFixture, +) -> None: """Test domain verification in a service call with no user.""" mock_registry( hass, @@ -1928,9 +2002,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: """Define a protected service.""" calls.append(call) - protected_mock_service = service.verify_domain_control(hass, "test_domain")( - mock_service_log - ) + protected_mock_service = decorator(hass, "test_domain")(mock_service_log) hass.services.async_register( "test_domain", "test_service", protected_mock_service, schema=None @@ -1946,6 +2018,8 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: assert len(calls) == 1 + assert (_DEPRECATED_VERIFY_DOMAIN_CONTROL_MESSAGE in caplog.text) == in_caplog + async def test_extract_from_service_available_device(hass: HomeAssistant) -> None: """Test the extraction of entity from service and device is available."""