Deprecate legacy light module is_capability checks (#1297)

Deprecate the `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`,
and `has_effects` attributes from the `Light` module, as consumers should use
`has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`,
`get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively.

Calling the deprecated attributes will emit a `DeprecationWarning`
and type checkers will fail them.
This commit is contained in:
Steven B. 2025-01-22 10:26:37 +00:00 committed by GitHub
parent a03a4b1d63
commit fa0f7157c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 109 deletions

View File

@ -107,7 +107,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, Any, TypeAlias
@ -537,19 +537,52 @@ class Device(ABC):
return None
def _get_deprecated_callable_attribute(self, name: str) -> Any | None:
vals: dict[str, tuple[ModuleName, Callable[[Any], Any], str]] = {
"is_dimmable": (
Module.Light,
lambda c: c.has_feature("brightness"),
'light_module.has_feature("brightness")',
),
"is_color": (
Module.Light,
lambda c: c.has_feature("hsv"),
'light_module.has_feature("hsv")',
),
"is_variable_color_temp": (
Module.Light,
lambda c: c.has_feature("color_temp"),
'light_module.has_feature("color_temp")',
),
"valid_temperature_range": (
Module.Light,
lambda c: c._deprecated_valid_temperature_range(),
'minimum and maximum value of get_feature("color_temp")',
),
"has_effects": (
Module.Light,
lambda c: Module.LightEffect in c._device.modules,
"Module.LightEffect in device.modules",
),
}
if mod_call_msg := vals.get(name):
mod, call, msg = mod_call_msg
msg = f"{name} is deprecated, use: {msg} instead"
warn(msg, DeprecationWarning, stacklevel=2)
if (module := self.modules.get(mod)) is None:
raise AttributeError(f"Device has no attribute {name!r}")
return call(module)
return None
_deprecated_other_attributes = {
# light attributes
"is_color": (Module.Light, ["is_color"]),
"is_dimmable": (Module.Light, ["is_dimmable"]),
"is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]),
"brightness": (Module.Light, ["brightness"]),
"set_brightness": (Module.Light, ["set_brightness"]),
"hsv": (Module.Light, ["hsv"]),
"set_hsv": (Module.Light, ["set_hsv"]),
"color_temp": (Module.Light, ["color_temp"]),
"set_color_temp": (Module.Light, ["set_color_temp"]),
"valid_temperature_range": (Module.Light, ["valid_temperature_range"]),
"has_effects": (Module.Light, ["has_effects"]),
"_deprecated_set_light_state": (Module.Light, ["has_effects"]),
# led attributes
"led": (Module.Led, ["led"]),
@ -588,6 +621,9 @@ class Device(ABC):
msg = f"{name} is deprecated, use device_type property instead"
warn(msg, DeprecationWarning, stacklevel=2)
return self.device_type == dep_device_type_attr[1]
# callable
if (result := self._get_deprecated_callable_attribute(name)) is not None:
return result
# Other deprecated attributes
if (dep_attr := self._deprecated_other_attributes.get(name)) and (
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))

View File

@ -65,8 +65,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Annotated, NamedTuple
from typing import TYPE_CHECKING, Annotated, Any, NamedTuple
from warnings import warn
from ..exceptions import KasaException
from ..module import FeatureAttribute, Module
@ -100,34 +102,6 @@ class HSV(NamedTuple):
class Light(Module, ABC):
"""Base class for TP-Link Light."""
@property
@abstractmethod
def is_dimmable(self) -> bool:
"""Whether the light supports brightness changes."""
@property
@abstractmethod
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
@property
@abstractmethod
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
@property
@abstractmethod
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
@property
@abstractmethod
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
@property
@abstractmethod
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
@ -197,3 +171,44 @@ class Light(Module, ABC):
@abstractmethod
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""
def _deprecated_valid_temperature_range(self) -> ColorTempRange:
if not (temp := self.get_feature("color_temp")):
raise KasaException("Color temperature not supported")
return ColorTempRange(temp.minimum_value, temp.maximum_value)
def _deprecated_attributes(self, dep_name: str) -> str | None:
map: dict[str, str] = {
"is_color": "hsv",
"is_dimmable": "brightness",
"is_variable_color_temp": "color_temp",
}
return map.get(dep_name)
if not TYPE_CHECKING:
def __getattr__(self, name: str) -> Any:
if name == "valid_temperature_range":
msg = (
"valid_temperature_range is deprecated, use "
'get_feature("color_temp") minimum_value '
" and maximum_value instead"
)
warn(msg, DeprecationWarning, stacklevel=2)
res = self._deprecated_valid_temperature_range()
return res
if name == "has_effects":
msg = (
"has_effects is deprecated, check `Module.LightEffect "
"in device.modules` instead"
)
warn(msg, DeprecationWarning, stacklevel=2)
return Module.LightEffect in self._device.modules
if attr := self._deprecated_attributes(name):
msg = f'{name} is deprecated, use has_feature("{attr}") instead'
warn(msg, DeprecationWarning, stacklevel=2)
return self.has_feature(attr)
raise AttributeError(f"Energy module has no attribute {name!r}")

View File

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Annotated, cast
from ...device_type import DeviceType
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import HSV, LightState
from ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute
from ..iotmodule import IotModule
@ -48,6 +48,8 @@ class Light(IotModule, LightInterface):
)
)
if device._is_variable_color_temp:
if TYPE_CHECKING:
assert isinstance(device, IotBulb)
self._add_feature(
Feature(
device=device,
@ -56,7 +58,7 @@ class Light(IotModule, LightInterface):
container=self,
attribute_getter="color_temp",
attribute_setter="set_color_temp",
range_getter="valid_temperature_range",
range_getter=lambda: device._valid_temperature_range,
category=Feature.Category.Primary,
type=Feature.Type.Number,
)
@ -90,11 +92,6 @@ class Light(IotModule, LightInterface):
return cast("IotBulb", self._device)
return None
@property # type: ignore
def is_dimmable(self) -> int:
"""Whether the bulb supports brightness changes."""
return self._device._is_dimmable
@property # type: ignore
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
@ -112,27 +109,6 @@ class Light(IotModule, LightInterface):
LightState(brightness=brightness, transition=transition)
)
@property
def is_color(self) -> bool:
"""Whether the light supports color changes."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb._is_color
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb._is_variable_color_temp
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb._has_effects
@property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb.
@ -164,18 +140,6 @@ class Light(IotModule, LightInterface):
raise KasaException("Light does not support color.")
return await bulb._set_hsv(hue, saturation, value, transition=transition)
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if (
bulb := self._get_bulb_device()
) is None or not bulb._is_variable_color_temp:
raise KasaException("Light does not support colortemp.")
return bulb._valid_temperature_range
@property
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes."""

View File

@ -7,7 +7,7 @@ from typing import Annotated
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import HSV, LightState
from ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute, Module
from ..smartmodule import SmartModule
@ -34,32 +34,6 @@ class Light(SmartModule, LightInterface):
"""Query to execute during the update cycle."""
return {}
@property
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
return Module.Color in self._device.modules
@property
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
return Module.Brightness in self._device.modules
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
return Module.ColorTemperature in self._device.modules
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Color temperature not supported")
return self._device.modules[Module.ColorTemperature].valid_temperature_range
@property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb.
@ -82,7 +56,7 @@ class Light(SmartModule, LightInterface):
@property
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
if Module.Brightness not in self._device.modules:
if Module.Brightness not in self._device.modules: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return self._device.modules[Module.Brightness].brightness
@ -135,16 +109,11 @@ class Light(SmartModule, LightInterface):
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if Module.Brightness not in self._device.modules:
if Module.Brightness not in self._device.modules: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return await self._device.modules[Module.Brightness].set_brightness(brightness)
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return Module.LightEffect in self._device.modules
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""
state_dict = asdict(state)

View File

@ -1,5 +1,9 @@
from __future__ import annotations
import re
from collections.abc import Callable
from contextlib import nullcontext
import pytest
from kasa import Device, DeviceType, KasaException, Module
@ -180,3 +184,67 @@ async def test_non_variable_temp(dev: Device):
@bulb
def test_device_type_bulb(dev: Device):
assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip}
@pytest.mark.parametrize(
("attribute", "use_msg", "use_fn"),
[
pytest.param(
"is_color",
'use has_feature("hsv") instead',
lambda device, mod: mod.has_feature("hsv"),
id="is_color",
),
pytest.param(
"is_dimmable",
'use has_feature("brightness") instead',
lambda device, mod: mod.has_feature("brightness"),
id="is_dimmable",
),
pytest.param(
"is_variable_color_temp",
'use has_feature("color_temp") instead',
lambda device, mod: mod.has_feature("color_temp"),
id="is_variable_color_temp",
),
pytest.param(
"has_effects",
"check `Module.LightEffect in device.modules` instead",
lambda device, mod: Module.LightEffect in device.modules,
id="has_effects",
),
],
)
@bulb
async def test_deprecated_light_is_has_attributes(
dev: Device, attribute: str, use_msg: str, use_fn: Callable[[Device, Module], bool]
):
light = dev.modules.get(Module.Light)
assert light
msg = f"{attribute} is deprecated, {use_msg}"
with pytest.deprecated_call(match=(re.escape(msg))):
result = getattr(light, attribute)
assert result == use_fn(dev, light)
@bulb
async def test_deprecated_light_valid_temperature_range(dev: Device):
light = dev.modules.get(Module.Light)
assert light
color_temp = light.has_feature("color_temp")
dep_msg = (
"valid_temperature_range is deprecated, use "
'get_feature("color_temp") minimum_value '
" and maximum_value instead"
)
exc_context = pytest.raises(KasaException, match="Color temperature not supported")
expected_context = nullcontext() if color_temp else exc_context
with (
expected_context,
pytest.deprecated_call(match=(re.escape(dep_msg))),
):
assert light.valid_temperature_range # type: ignore[attr-defined]