mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-04-30 18:46:24 +00:00
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:
parent
a03a4b1d63
commit
fa0f7157c6
@ -107,7 +107,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, tzinfo
|
from datetime import datetime, tzinfo
|
||||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||||
@ -537,19 +537,52 @@ class Device(ABC):
|
|||||||
|
|
||||||
return None
|
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 = {
|
_deprecated_other_attributes = {
|
||||||
# light 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"]),
|
"brightness": (Module.Light, ["brightness"]),
|
||||||
"set_brightness": (Module.Light, ["set_brightness"]),
|
"set_brightness": (Module.Light, ["set_brightness"]),
|
||||||
"hsv": (Module.Light, ["hsv"]),
|
"hsv": (Module.Light, ["hsv"]),
|
||||||
"set_hsv": (Module.Light, ["set_hsv"]),
|
"set_hsv": (Module.Light, ["set_hsv"]),
|
||||||
"color_temp": (Module.Light, ["color_temp"]),
|
"color_temp": (Module.Light, ["color_temp"]),
|
||||||
"set_color_temp": (Module.Light, ["set_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"]),
|
"_deprecated_set_light_state": (Module.Light, ["has_effects"]),
|
||||||
# led attributes
|
# led attributes
|
||||||
"led": (Module.Led, ["led"]),
|
"led": (Module.Led, ["led"]),
|
||||||
@ -588,6 +621,9 @@ class Device(ABC):
|
|||||||
msg = f"{name} is deprecated, use device_type property instead"
|
msg = f"{name} is deprecated, use device_type property instead"
|
||||||
warn(msg, DeprecationWarning, stacklevel=2)
|
warn(msg, DeprecationWarning, stacklevel=2)
|
||||||
return self.device_type == dep_device_type_attr[1]
|
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
|
# Other deprecated attributes
|
||||||
if (dep_attr := self._deprecated_other_attributes.get(name)) and (
|
if (dep_attr := self._deprecated_other_attributes.get(name)) and (
|
||||||
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))
|
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))
|
||||||
|
@ -65,8 +65,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
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
|
from ..module import FeatureAttribute, Module
|
||||||
|
|
||||||
|
|
||||||
@ -100,34 +102,6 @@ class HSV(NamedTuple):
|
|||||||
class Light(Module, ABC):
|
class Light(Module, ABC):
|
||||||
"""Base class for TP-Link Light."""
|
"""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
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||||
@ -197,3 +171,44 @@ class Light(Module, ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def set_state(self, state: LightState) -> dict:
|
async def set_state(self, state: LightState) -> dict:
|
||||||
"""Set the light state."""
|
"""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}")
|
||||||
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Annotated, cast
|
|||||||
from ...device_type import DeviceType
|
from ...device_type import DeviceType
|
||||||
from ...exceptions import KasaException
|
from ...exceptions import KasaException
|
||||||
from ...feature import Feature
|
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 ...interfaces.light import Light as LightInterface
|
||||||
from ...module import FeatureAttribute
|
from ...module import FeatureAttribute
|
||||||
from ..iotmodule import IotModule
|
from ..iotmodule import IotModule
|
||||||
@ -48,6 +48,8 @@ class Light(IotModule, LightInterface):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if device._is_variable_color_temp:
|
if device._is_variable_color_temp:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(device, IotBulb)
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device=device,
|
device=device,
|
||||||
@ -56,7 +58,7 @@ class Light(IotModule, LightInterface):
|
|||||||
container=self,
|
container=self,
|
||||||
attribute_getter="color_temp",
|
attribute_getter="color_temp",
|
||||||
attribute_setter="set_color_temp",
|
attribute_setter="set_color_temp",
|
||||||
range_getter="valid_temperature_range",
|
range_getter=lambda: device._valid_temperature_range,
|
||||||
category=Feature.Category.Primary,
|
category=Feature.Category.Primary,
|
||||||
type=Feature.Type.Number,
|
type=Feature.Type.Number,
|
||||||
)
|
)
|
||||||
@ -90,11 +92,6 @@ class Light(IotModule, LightInterface):
|
|||||||
return cast("IotBulb", self._device)
|
return cast("IotBulb", self._device)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property # type: ignore
|
|
||||||
def is_dimmable(self) -> int:
|
|
||||||
"""Whether the bulb supports brightness changes."""
|
|
||||||
return self._device._is_dimmable
|
|
||||||
|
|
||||||
@property # type: ignore
|
@property # type: ignore
|
||||||
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Return the current brightness in percentage."""
|
"""Return the current brightness in percentage."""
|
||||||
@ -112,27 +109,6 @@ class Light(IotModule, LightInterface):
|
|||||||
LightState(brightness=brightness, transition=transition)
|
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
|
@property
|
||||||
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||||
"""Return the current HSV state of the bulb.
|
"""Return the current HSV state of the bulb.
|
||||||
@ -164,18 +140,6 @@ class Light(IotModule, LightInterface):
|
|||||||
raise KasaException("Light does not support color.")
|
raise KasaException("Light does not support color.")
|
||||||
return await bulb._set_hsv(hue, saturation, value, transition=transition)
|
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
|
@property
|
||||||
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Whether the bulb supports color temperature changes."""
|
"""Whether the bulb supports color temperature changes."""
|
||||||
|
@ -7,7 +7,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from ...exceptions import KasaException
|
from ...exceptions import KasaException
|
||||||
from ...feature import Feature
|
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 ...interfaces.light import Light as LightInterface
|
||||||
from ...module import FeatureAttribute, Module
|
from ...module import FeatureAttribute, Module
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import SmartModule
|
||||||
@ -34,32 +34,6 @@ class Light(SmartModule, LightInterface):
|
|||||||
"""Query to execute during the update cycle."""
|
"""Query to execute during the update cycle."""
|
||||||
return {}
|
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
|
@property
|
||||||
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||||
"""Return the current HSV state of the bulb.
|
"""Return the current HSV state of the bulb.
|
||||||
@ -82,7 +56,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
@property
|
@property
|
||||||
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Return the current brightness in percentage."""
|
"""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.")
|
raise KasaException("Bulb is not dimmable.")
|
||||||
|
|
||||||
return self._device.modules[Module.Brightness].brightness
|
return self._device.modules[Module.Brightness].brightness
|
||||||
@ -135,16 +109,11 @@ class Light(SmartModule, LightInterface):
|
|||||||
:param int brightness: brightness in percent
|
:param int brightness: brightness in percent
|
||||||
:param int transition: transition in milliseconds.
|
: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.")
|
raise KasaException("Bulb is not dimmable.")
|
||||||
|
|
||||||
return await self._device.modules[Module.Brightness].set_brightness(brightness)
|
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:
|
async def set_state(self, state: LightState) -> dict:
|
||||||
"""Set the light state."""
|
"""Set the light state."""
|
||||||
state_dict = asdict(state)
|
state_dict = asdict(state)
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kasa import Device, DeviceType, KasaException, Module
|
from kasa import Device, DeviceType, KasaException, Module
|
||||||
@ -180,3 +184,67 @@ async def test_non_variable_temp(dev: Device):
|
|||||||
@bulb
|
@bulb
|
||||||
def test_device_type_bulb(dev: Device):
|
def test_device_type_bulb(dev: Device):
|
||||||
assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip}
|
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]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user