diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 29d9237de05..ab72fa02f6c 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -138,6 +138,32 @@ def deprecated_function[**_P, _R]( return deprecated_decorator +def deprecate_hass_binding[**_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 + + if isinstance(args[0], HomeAssistant): + _print_deprecation_warning( + func, + "without hass", + "argument", + "called with hass as the first argument", + breaks_in_ha_version, + ) + args = args[1:] # type: ignore[assignment] + 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 1d4dac10c27..5cd48e09ef6 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -9,7 +9,7 @@ from enum import Enum from functools import cache, partial import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, cast, override +from typing import TYPE_CHECKING, Any, TypedDict, cast, overload, override import voluptuous as vol @@ -57,7 +57,7 @@ from . import ( template, translation, ) -from .deprecation import deprecated_class, deprecated_function +from .deprecation import deprecate_hass_binding, deprecated_class, deprecated_function from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -986,10 +986,22 @@ def async_register_admin_service( ) -@bind_hass +# Overloads can be dropped when all core calls have been updated to drop hass argument +@overload +def verify_domain_control( + domain: str, +) -> Callable[[Callable[[ServiceCall], Any]], Callable[[ServiceCall], Any]]: ... +@overload +def verify_domain_control( + hass: HomeAssistant, + domain: str, +) -> Callable[[Callable[[ServiceCall], Any]], Callable[[ServiceCall], Any]]: ... + + +@deprecate_hass_binding(breaks_in_ha_version="2026.2") # type: ignore[misc] @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.""" @@ -1005,6 +1017,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_service.py b/tests/helpers/test_service.py index 0191827cd58..079b6064095 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 @@ -1711,7 +1711,27 @@ 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 = ( + "verify_domain_control is a deprecated argument which will be removed" + " in HA Core 2026.2. Use without hass instead" +) + + +@pytest.mark.parametrize( + # Check that with or without hass behaves the same + ("decorator", "in_caplog"), + [ + (service.verify_domain_control, True), # deprecated with hass + (lambda _, domain: service.verify_domain_control(domain), False), + ], +) +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 = [] @@ -1720,10 +1740,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), # deprecated with hass + (lambda _, domain: service.verify_domain_control(domain), False), + ], +) +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 = [] @@ -1735,9 +1771,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 @@ -1753,9 +1787,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), # deprecated with hass + (lambda _, domain: service.verify_domain_control(domain), False), + ], +) 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( @@ -1775,9 +1823,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 @@ -1794,9 +1840,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), # deprecated with hass + (lambda _, domain: service.verify_domain_control(domain), False), + ], +) 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( @@ -1816,9 +1876,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 @@ -1834,8 +1892,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), # deprecated with hass + (lambda _, domain: service.verify_domain_control(domain), False), + ], +) +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, @@ -1854,9 +1927,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 @@ -1872,6 +1943,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."""