From dd338799d4b8a05983ddf6819132ae0b875bc041 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Dec 2023 20:00:55 +0100 Subject: [PATCH] Make it possible to inherit EntityDescription in frozen and mutable dataclasses (#105211) --- .../bluetooth/passive_update_processor.py | 4 +- homeassistant/components/iotawatt/sensor.py | 20 +-- .../components/litterrobot/vacuum.py | 2 +- homeassistant/components/zwave_js/sensor.py | 114 ++++++++-------- homeassistant/helpers/entity.py | 19 ++- homeassistant/util/frozen_dataclass_compat.py | 127 ++++++++++++++++++ tests/helpers/snapshots/test_entity.ambr | 45 +++++++ tests/helpers/test_entity.py | 51 ++++++- 8 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 homeassistant/util/frozen_dataclass_compat.py create mode 100644 tests/helpers/snapshots/test_entity.ambr diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8da0d2c462b..eeccf081b55 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -94,7 +94,7 @@ def deserialize_entity_description( ) -> EntityDescription: """Deserialize an entity description.""" result: dict[str, Any] = {} - for field in cached_fields(descriptions_class): # type: ignore[arg-type] + for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual # type instead of a str so we could avoid writing this @@ -114,7 +114,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An as_dict = dataclasses.asdict(description) return { field.name: as_dict[field.name] - for field in cached_fields(type(description)) # type: ignore[arg-type] + for field in cached_fields(type(description)) if field.default != as_dict.get(field.name) } diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 27ecc1574e3..7dd26c46201 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -45,14 +45,14 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "Amps": IotaWattSensorEntityDescription( - "Amps", + key="Amps", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( - "Hz", + key="Hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, @@ -60,7 +60,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( - "PF", + key="PF", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER_FACTOR, @@ -68,40 +68,40 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( - "Watts", + key="Watts", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), "WattHours": IotaWattSensorEntityDescription( - "WattHours", + key="WattHours", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), "VA": IotaWattSensorEntityDescription( - "VA", + key="VA", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.APPARENT_POWER, entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( - "VAR", + key="VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( - "VARh", + key="VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( - "Volts", + key="Volts", native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, @@ -125,7 +125,7 @@ async def async_setup_entry( created.add(key) data = coordinator.data["sensors"][key] description = ENTITY_DESCRIPTION_KEY_MAP.get( - data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + data.getUnit(), IotaWattSensorEntityDescription(key="base_sensor") ) return IotaWattSensor( diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 4b1a8effb98..a86f1e4be00 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -47,7 +47,7 @@ LITTER_BOX_STATUS_STATE_MAP = { } LITTER_BOX_ENTITY = StateVacuumEntityDescription( - "litter_box", translation_key="litter_box" + key="litter_box", translation_key="litter_box" ) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 8d42bcfb366..56ed3f010b8 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -111,20 +111,20 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ tuple[str, str], SensorEntityDescription ] = { (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_BATTERY, + key=ENTITY_DESC_KEY_BATTERY, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( - ENTITY_DESC_KEY_CURRENT, + key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), (ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.VOLT): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -133,7 +133,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.MILLIVOLT, ): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -142,67 +142,67 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, UnitOfEnergy.KILO_WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), (ENTITY_DESC_KEY_POWER, UnitOfPower.WATT): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), (ENTITY_DESC_KEY_POWER_FACTOR, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CO, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_CO2, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO2, + key=ENTITY_DESC_KEY_CO2, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_HUMIDITY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_ILLUMINANCE, LIGHT_LUX): SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.KPA): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.KPA, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.PSI): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.PSI, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.INHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.INHG, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.MMHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.MMHG, @@ -211,7 +211,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -219,7 +219,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), (ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.CELSIUS): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -228,7 +228,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, @@ -237,7 +237,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.CELSIUS, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), @@ -245,7 +245,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), @@ -253,13 +253,13 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.SECONDS, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, name="Energy production time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, ), (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, ), @@ -267,7 +267,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, name="Energy production today", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -277,7 +277,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, name="Energy production total", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -287,7 +287,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, UnitOfPower.WATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, name="Energy production power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -298,41 +298,41 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ # These descriptions are without device class. ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_CO: SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + key=ENTITY_DESC_KEY_ENERGY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_MEASUREMENT, + key=ENTITY_DESC_KEY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( - ENTITY_DESC_KEY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( - ENTITY_DESC_KEY_UV_INDEX, + key=ENTITY_DESC_KEY_UV_INDEX, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), @@ -342,80 +342,80 @@ ENTITY_DESCRIPTION_KEY_MAP = { # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ SensorEntityDescription( - "messagesTX", + key="messagesTX", name="Successful messages (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesRX", + key="messagesRX", name="Successful messages (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedTX", + key="messagesDroppedTX", name="Messages dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedRX", + key="messagesDroppedRX", name="Messages dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "NAK", + key="NAK", name="Messages not accepted", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutCallback", + key="timeoutCallback", name="Timed out callbacks", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "backgroundRSSI.channel0.average", + key="backgroundRSSI.channel0.average", name="Average background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel0.current", + key="backgroundRSSI.channel0.current", name="Current background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel1.average", + key="backgroundRSSI.channel1.average", name="Average background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel1.current", + key="backgroundRSSI.channel1.current", name="Current background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel2.average", + key="backgroundRSSI.channel2.average", name="Average background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel2.current", + key="backgroundRSSI.channel2.current", name="Current background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -426,39 +426,39 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ SensorEntityDescription( - "commandsRX", + key="commandsRX", name="Successful commands (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsTX", + key="commandsTX", name="Successful commands (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedRX", + key="commandsDroppedRX", name="Commands dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedTX", + key="commandsDroppedTX", name="Commands dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "rtt", + key="rtt", name="Round Trip Time", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "rssi", + key="rssi", name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -478,7 +478,7 @@ def get_entity_description( ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, SensorEntityDescription( - "base_sensor", native_unit_of_measurement=data.unit_of_measurement + key="base_sensor", native_unit_of_measurement=data.unit_of_measurement ), ), ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7877ca0e613..6446a4fe6d6 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from dataclasses import dataclass +import dataclasses from datetime import timedelta from enum import Enum, auto import functools as ft @@ -23,6 +23,7 @@ from typing import ( final, ) +from typing_extensions import dataclass_transform import voluptuous as vol from homeassistant.backports.functools import cached_property @@ -51,6 +52,7 @@ from homeassistant.exceptions import ( ) from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify +from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -218,8 +220,17 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass(slots=True) -class EntityDescription: +@dataclass_transform( + field_specifiers=(dataclasses.field, dataclasses.Field), + kw_only_default=True, # Set to allow setting kw_only in child classes +) +class _EntityDescriptionBase: + """Add PEP 681 decorator (dataclass transform).""" + + +class EntityDescription( + _EntityDescriptionBase, metaclass=FrozenOrThawed, frozen_or_thawed=True +): """A class that describes Home Assistant entities.""" # This is the key identifier for this entity @@ -1245,7 +1256,7 @@ class Entity(ABC): ) -@dataclass(slots=True) +@dataclasses.dataclass(slots=True) class ToggleEntityDescription(EntityDescription): """A class that describes toggle entities.""" diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py new file mode 100644 index 00000000000..96053844ab5 --- /dev/null +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -0,0 +1,127 @@ +"""Utility to create classes from which frozen or mutable dataclasses can be derived. + +This module enabled a non-breaking transition from mutable to frozen dataclasses +derived from EntityDescription and sub classes thereof. +""" +from __future__ import annotations + +import dataclasses +import sys +from typing import Any + + +def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: + """Return a list of dataclass fields. + + Extracted from dataclasses._process_class. + """ + # pylint: disable=protected-access + cls_annotations = cls.__dict__.get("__annotations__", {}) + + cls_fields: list[dataclasses.Field[Any]] = [] + + _dataclasses = sys.modules[dataclasses.__name__] + for name, _type in cls_annotations.items(): + # See if this is a marker to change the value of kw_only. + if dataclasses._is_kw_only(type, _dataclasses) or ( # type: ignore[attr-defined] + isinstance(_type, str) + and dataclasses._is_type( # type: ignore[attr-defined] + _type, + cls, + _dataclasses, + dataclasses.KW_ONLY, + dataclasses._is_kw_only, # type: ignore[attr-defined] + ) + ): + kw_only = True + else: + # Otherwise it's a field of some type. + cls_fields.append(dataclasses._get_field(cls, name, _type, kw_only)) # type: ignore[attr-defined] + + return [(field.name, field.type, field) for field in cls_fields] + + +class FrozenOrThawed(type): + """Metaclass which which makes classes which behave like a dataclass. + + This allows child classes to be either mutable or frozen dataclasses. + """ + + def _make_dataclass(cls, name: str, bases: tuple[type, ...], kw_only: bool) -> None: + class_fields = _class_fields(cls, kw_only) + dataclass_bases = [] + for base in bases: + dataclass_bases.append(getattr(base, "_dataclass", base)) + cls._dataclass = dataclasses.make_dataclass( + f"{name}_dataclass", class_fields, bases=tuple(dataclass_bases), frozen=True + ) + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + frozen_or_thawed: bool = False, + **kwargs: Any, + ) -> Any: + """Pop frozen_or_thawed and store it in the namespace.""" + namespace["_FrozenOrThawed__frozen_or_thawed"] = frozen_or_thawed + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Optionally create a dataclass and store it in cls._dataclass. + + A dataclass will be created if frozen_or_thawed is set, if not we assume the + class will be a real dataclass, i.e. it's decorated with @dataclass. + """ + if not namespace["_FrozenOrThawed__frozen_or_thawed"]: + parent = cls.__mro__[1] + # This class is a real dataclass, optionally inject the parent's annotations + if dataclasses.is_dataclass(parent) or not hasattr(parent, "_dataclass"): + # Rely on dataclass inheritance + return + # Parent is not a dataclass, inject its annotations + cls.__annotations__ = ( + parent._dataclass.__annotations__ | cls.__annotations__ + ) + return + + # First try without setting the kw_only flag, and if that fails, try setting it + try: + cls._make_dataclass(name, bases, False) + except TypeError: + cls._make_dataclass(name, bases, True) + + def __delattr__(self: object, name: str) -> None: + """Delete an attribute. + + If self is a real dataclass, this is called if the dataclass is not frozen. + If self is not a real dataclass, forward to cls._dataclass.__delattr. + """ + if dataclasses.is_dataclass(self): + return object.__delattr__(self, name) + return self._dataclass.__delattr__(self, name) # type: ignore[attr-defined, no-any-return] + + def __setattr__(self: object, name: str, value: Any) -> None: + """Set an attribute. + + If self is a real dataclass, this is called if the dataclass is not frozen. + If self is not a real dataclass, forward to cls._dataclass.__setattr__. + """ + if dataclasses.is_dataclass(self): + return object.__setattr__(self, name, value) + return self._dataclass.__setattr__(self, name, value) # type: ignore[attr-defined, no-any-return] + + # Set generated dunder methods from the dataclass + # MyPy doesn't understand what's happening, so we ignore it + cls.__delattr__ = __delattr__ # type: ignore[assignment, method-assign] + cls.__eq__ = cls._dataclass.__eq__ # type: ignore[method-assign] + cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] + cls.__repr__ = cls._dataclass.__repr__ # type: ignore[method-assign] + cls.__setattr__ = __setattr__ # type: ignore[assignment, method-assign] diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr new file mode 100644 index 00000000000..3b04286b62f --- /dev/null +++ b/tests/helpers/snapshots/test_entity.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_entity_description_as_dataclass + EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None) +# --- +# name: test_entity_description_as_dataclass.1 + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.1 + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.2 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.3 + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4076afcfad0..66ba9f947c9 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import MagicMock, PropertyMock, patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.const import ( @@ -966,7 +967,7 @@ async def test_entity_description_fallback() -> None: ent_with_description = entity.Entity() ent_with_description.entity_description = entity.EntityDescription(key="test") - for field in dataclasses.fields(entity.EntityDescription): + for field in dataclasses.fields(entity.EntityDescription._dataclass): if field.name == "key": continue @@ -1657,3 +1658,51 @@ async def test_change_entity_id( assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + + +def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): + """Test EntityDescription behaves like a dataclass.""" + + obj = entity.EntityDescription("blah", device_class="test") + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + assert obj == snapshot + assert obj == entity.EntityDescription("blah", device_class="test") + assert repr(obj) == snapshot + + +def test_extending_entity_description(snapshot: SnapshotAssertion): + """Test extending entity descriptions.""" + + @dataclasses.dataclass(frozen=True) + class FrozenEntityDescription(entity.EntityDescription): + extra: str = None + + obj = FrozenEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == FrozenEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + @dataclasses.dataclass + class ThawedEntityDescription(entity.EntityDescription): + extra: str = None + + obj = ThawedEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == ThawedEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + obj.name = "mutate" + assert obj.name == "mutate" + delattr(obj, "key") + assert not hasattr(obj, "key")