diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index a3303c525cb29a373f00c382eb08d192e900b458..dbed80a83f459834fb4cd905aa056b6d75177cff 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Literal, final @@ -16,6 +17,10 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -121,34 +126,92 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the BinarySensorDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] -DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value -DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value -DEVICE_CLASS_CO = BinarySensorDeviceClass.CO.value -DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value -DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value -DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value -DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value -DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value -DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value -DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value -DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value -DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value -DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value -DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value -DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value -DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value -DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value -DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value -DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value -DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value -DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value -DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value -DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value -DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value -DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value -DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value -DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum( + BinarySensorDeviceClass.CO, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum( + BinarySensorDeviceClass.COLD, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum( + BinarySensorDeviceClass.CONNECTIVITY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.GARAGE_DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum( + BinarySensorDeviceClass.GAS, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum( + BinarySensorDeviceClass.HEAT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum( + BinarySensorDeviceClass.LIGHT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum( + BinarySensorDeviceClass.LOCK, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOISTURE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOTION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOVING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum( + BinarySensorDeviceClass.OCCUPANCY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum( + BinarySensorDeviceClass.OPENING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum( + BinarySensorDeviceClass.PLUG, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum( + BinarySensorDeviceClass.POWER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum( + BinarySensorDeviceClass.PRESENCE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum( + BinarySensorDeviceClass.PROBLEM, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum( + BinarySensorDeviceClass.RUNNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum( + BinarySensorDeviceClass.SAFETY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum( + BinarySensorDeviceClass.SMOKE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum( + BinarySensorDeviceClass.SOUND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum( + BinarySensorDeviceClass.TAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum( + BinarySensorDeviceClass.UPDATE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum( + BinarySensorDeviceClass.VIBRATION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + BinarySensorDeviceClass.WINDOW, "2025.1" +) + +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) # mypy: disallow-any-generics diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 20dbacde480b3127bf323c23dea7e27b7c00729c..740f96044a5318ccc82f300b7c7a91463b11cacf 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress +from enum import Enum import functools import inspect import logging -from typing import Any, ParamSpec, TypeVar +from typing import Any, NamedTuple, ParamSpec, TypeVar from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.exceptions import HomeAssistantError @@ -153,7 +154,25 @@ def _print_deprecation_warning( verb: str, breaks_in_ha_version: str | None, ) -> None: - logger = logging.getLogger(obj.__module__) + _print_deprecation_warning_internal( + obj.__name__, + obj.__module__, + replacement, + description, + verb, + breaks_in_ha_version, + ) + + +def _print_deprecation_warning_internal( + obj_name: str, + module_name: str, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, +) -> None: + logger = logging.getLogger(module_name) if breaks_in_ha_version: breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" else: @@ -163,7 +182,7 @@ def _print_deprecation_warning( except MissingIntegrationFrame: logger.warning( "%s is a deprecated %s%s. Use %s instead", - obj.__name__, + obj_name, description, breaks_in, replacement, @@ -183,7 +202,7 @@ def _print_deprecation_warning( "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), - obj.__name__, + obj_name, verb, integration_frame.integration, description, @@ -194,10 +213,69 @@ def _print_deprecation_warning( else: logger.warning( "%s was %s from %s, this is a deprecated %s%s. Use %s instead", - obj.__name__, + obj_name, verb, integration_frame.integration, description, breaks_in, replacement, ) + + +class DeprecatedConstant(NamedTuple): + """Deprecated constant.""" + + value: Any + replacement: str + breaks_in_ha_version: str | None + + +class DeprecatedConstantEnum(NamedTuple): + """Deprecated constant.""" + + enum: Enum + breaks_in_ha_version: str | None + + +def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_name = module_globals.get("__name__") + logger = logging.getLogger(module_name) + if (deprecated_const := module_globals.get(f"_DEPRECATED_{name}")) is None: + raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") + if isinstance(deprecated_const, DeprecatedConstant): + value = deprecated_const.value + replacement = deprecated_const.replacement + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + elif isinstance(deprecated_const, DeprecatedConstantEnum): + value = deprecated_const.enum.value + replacement = ( + f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" + ) + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + else: + msg = ( + f"Value of _DEPRECATED_{name!r} is an instance of {type(deprecated_const)} " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" + ) + + logger.debug(msg) + # PEP 562 -- Module __getattr__ and __dir__ + # specifies that __getattr__ should raise AttributeError if the attribute is not + # found. + # https://peps.python.org/pep-0562/#specification + raise AttributeError(msg) # noqa: TRY004 + + _print_deprecation_warning_internal( + name, + module_name or __name__, + replacement, + "constant", + "used", + breaks_in_ha_version, + ) + return value diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 074ecb4434a79e361a8a95eff6b1f4afa114e5aa..782896b4dce7315c063ad42d85a5014fcb0d8a2e 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -1,5 +1,6 @@ """The tests for the Binary sensor component.""" from collections.abc import Generator +import logging from unittest import mock import pytest @@ -19,6 +20,9 @@ from tests.common import ( mock_platform, ) from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor +from tests.testing_config.custom_components.test_constant_deprecation.binary_sensor import ( + import_deprecated, +) TEST_DOMAIN = "test" @@ -194,3 +198,26 @@ async def test_entity_category_config_raises_error( "Entity binary_sensor.test2 cannot be added as the entity category is set to config" in caplog.text ) + + +@pytest.mark.parametrize( + "device_class", + list(binary_sensor.BinarySensorDeviceClass), +) +def test_deprecated_constant_device_class( + caplog: pytest.LogCaptureFixture, + device_class: binary_sensor.BinarySensorDeviceClass, +) -> None: + """Test deprecated binary sensor device classes.""" + import_deprecated(device_class) + + assert ( + "homeassistant.components.binary_sensor", + logging.WARNING, + ( + f"DEVICE_CLASS_{device_class.name} was used from test_constant_deprecation," + " this is a deprecated constant which will be removed in HA Core 2025.1. " + f"Use BinarySensorDeviceClass.{device_class.name} instead, please report " + "it to the author of the 'test_constant_deprecation' custom integration" + ), + ) in caplog.record_tuples diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 46716263d5bea411d13a98f267f24c47e8d7fc43..6cff4781583fd1fba49cb2b5fea16080b525a9f5 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,10 +1,15 @@ """Test deprecation helpers.""" +import logging +import sys from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, deprecated_class, deprecated_function, deprecated_substitute, @@ -247,3 +252,92 @@ def test_deprecated_function_called_from_custom_integration( "Use new_function instead, please report it to the author of the " "'hue' custom integration" ) in caplog.text + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name", "extra_extra_msg"), + [ + ("homeassistant.components.hue.light", ""), # builtin integration + ( + "config.custom_components.hue.light", + ", please report it to the author of the 'hue' custom integration", + ), # custom component integration + ], +) +def test_check_if_deprecated_constant( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum, + extra_msg: str, + module_name: str, + extra_extra_msg: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + filename = f"/home/paulus/{module_name.replace('.', '/')}.py" + + # mock module for homeassistant/helpers/frame.py#get_integration_frame + sys.modules[module_name] = Mock(__file__=filename) + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename=filename, + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == deprecated_constant.value + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT was used from hue, this is a deprecated constant{extra_msg}{extra_extra_msg}", + ) in caplog.record_tuples + + +def test_test_check_if_deprecated_constant_invalid( + caplog: pytest.LogCaptureFixture +) -> None: + """Test check_if_deprecated_constant will raise an attribute error and create an log entry on an invalid deprecation type.""" + module_name = "homeassistant.components.hue.light" + module_globals = {"__name__": module_name, "_DEPRECATED_TEST_CONSTANT": 1} + name = "TEST_CONSTANT" + + excepted_msg = ( + f"Value of _DEPRECATED_{name!r} is an instance of <class 'int'> " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" + ) + + with pytest.raises(AttributeError, match=excepted_msg): + check_if_deprecated_constant(name, module_globals) + + assert (module_name, logging.DEBUG, excepted_msg) in caplog.record_tuples diff --git a/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..e9e85dfb6390eb606f0b7c79156234801250ae8b --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/binary_sensor.py @@ -0,0 +1,7 @@ +"""Test deprecated binary sensor device classes.""" +from homeassistant.components import binary_sensor + + +def import_deprecated(device_class: binary_sensor.BinarySensorDeviceClass): + """Import deprecated device class constant.""" + getattr(binary_sensor, f"DEVICE_CLASS_{device_class.name}")