From 604b36b9f6982f3e53004896536ec9d1017dbd0e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 12:45:13 +0000 Subject: [PATCH] Exclude __getattr__ for deprecated attributes from type checkers --- kasa/__init__.py | 45 ++++++++++++++++++++------------------- kasa/cli/light.py | 12 ++++++++--- kasa/device.py | 40 ++++++++++++++++++---------------- kasa/interfaces/energy.py | 16 ++++++++------ kasa/iot/iotdimmer.py | 2 +- kasa/iot/iotmodule.py | 7 +++++- kasa/iot/iotstrip.py | 4 +++- kasa/iot/modules/light.py | 2 ++ kasa/smart/smartdevice.py | 4 ++++ tests/test_emeter.py | 3 +++ 10 files changed, 82 insertions(+), 53 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 7fb80ab57..059e093e2 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -97,28 +97,29 @@ "DeviceFamilyType": DeviceFamily, } - -def __getattr__(name: str) -> Any: - if name in deprecated_names: - warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) - return globals()[f"_deprecated_{name}"] - if name in deprecated_smart_devices: - new_class = deprecated_smart_devices[name] - package_name = ".".join(new_class.__module__.split(".")[:-1]) - warn( - f"{name} is deprecated, use {new_class.__name__} " - + f"from package {package_name} instead or use Discover.discover_single()" - + " and Device.connect() to support new protocols", - DeprecationWarning, - stacklevel=2, - ) - return new_class - if name in deprecated_classes: - new_class = deprecated_classes[name] # type: ignore[assignment] - msg = f"{name} is deprecated, use {new_class.__name__} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return new_class - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: + if name in deprecated_names: + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) + return globals()[f"_deprecated_{name}"] + if name in deprecated_smart_devices: + new_class = deprecated_smart_devices[name] + package_name = ".".join(new_class.__module__.split(".")[:-1]) + warn( + f"{name} is deprecated, use {new_class.__name__} from " + + f"package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", + DeprecationWarning, + stacklevel=2, + ) + return new_class + if name in deprecated_classes: + new_class = deprecated_classes[name] # type: ignore[assignment] + msg = f"{name} is deprecated, use {new_class.__name__} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return new_class + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") if TYPE_CHECKING: diff --git a/kasa/cli/light.py b/kasa/cli/light.py index 6b342c3da..4e7d23dc5 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -9,6 +9,7 @@ from kasa.iot import ( IotBulb, ) +from kasa.iot.modules import LightPreset as IotLightPreset from .common import echo, error, pass_dev_or_child @@ -148,7 +149,12 @@ def presets_list(dev: Device): @pass_dev_or_child async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): """Modify a preset.""" - for preset in dev.presets: + light_preset = dev.modules.get(Module.LightPreset) + if not light_preset or not isinstance(light_preset, IotLightPreset): + error("Modify presets not supported on device") + return + + for preset in light_preset._deprecated_presets: if preset.index == index: break else: @@ -166,7 +172,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper echo(f"Going to save preset: {preset}") - return await dev.save_preset(preset) + return await light_preset._deprecated_save_preset(preset) @light.command() @@ -176,7 +182,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper @click.option("--preset", type=int) async def turn_on_behavior(dev: Device, type, last, preset): """Modify bulb turn-on behavior.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): + if dev.device_type is not Device.Type.Bulb or not isinstance(dev, IotBulb): error("Presets only supported on iot bulbs") return settings = await dev.get_turn_on_behavior() diff --git a/kasa/device.py b/kasa/device.py index b0f110cbd..76d7a7c59 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -566,21 +566,25 @@ def _get_replacing_attr( "supported_modules": (None, ["modules"]), } - def __getattr__(self, name: str) -> Any: - # is_device_type - if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): - msg = f"{name} is deprecated, use device_type property instead" - warn(msg, DeprecationWarning, stacklevel=2) - return self.device_type == dep_device_type_attr[1] - # 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])) - is not None - ): - mod = dep_attr[0] - dev_or_mod = self.modules[mod] if mod else self - replacing = f"Module.{mod} in device.modules" if mod else replacing_attr - msg = f"{name} is deprecated, use: {replacing} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return getattr(dev_or_mod, replacing_attr) - raise AttributeError(f"Device has no attribute {name!r}") + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + # is_device_type + if dep_device_type_attr := self._deprecated_device_type_attributes.get( + name + ): + msg = f"{name} is deprecated, use device_type property instead" + warn(msg, DeprecationWarning, stacklevel=2) + return self.device_type == dep_device_type_attr[1] + # 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])) + is not None + ): + mod = dep_attr[0] + dev_or_mod = self.modules[mod] if mod else self + replacing = f"Module.{mod} in device.modules" if mod else replacing_attr + msg = f"{name} is deprecated, use: {replacing} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(dev_or_mod, replacing_attr) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 7092788ec..c57a3ed80 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from enum import IntFlag, auto -from typing import Any +from typing import TYPE_CHECKING, Any from warnings import warn from ..emeterstatus import EmeterStatus @@ -184,9 +184,11 @@ async def get_monthly_stats( "get_monthstat": "get_monthly_stats", } - def __getattr__(self, name: str) -> Any: - if attr := self._deprecated_attributes.get(name): - msg = f"{name} is deprecated, use {attr} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return getattr(self, attr) - raise AttributeError(f"Energy module has no attribute {name!r}") + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + if attr := self._deprecated_attributes.get(name): + msg = f"{name} is deprecated, use {attr} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(self, attr) + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 0c9eb3ea7..3960e641b 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -154,7 +154,7 @@ async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: """ if transition is not None: return await self.set_dimmer_transition( - brightness=self.brightness, transition=transition + brightness=self._brightness, transition=transition ) return await super().turn_on() diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index ddb0da2c1..115e9e823 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -3,13 +3,16 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from ..exceptions import KasaException from ..module import Module _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from .iotdevice import IotDevice + def _merge_dict(dest: dict, source: dict) -> dict: """Update dict recursively.""" @@ -27,6 +30,8 @@ def _merge_dict(dest: dict, source: dict) -> dict: class IotModule(Module): """Base class implemention for all IOT modules.""" + _device: IotDevice + async def call(self, method: str, params: dict | None = None) -> dict: """Call the given method with the given parameters.""" return await self._device._query_helper(self._module, method, params) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 849f92f23..a4b2ab996 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -5,7 +5,7 @@ import logging from collections import defaultdict from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -145,6 +145,8 @@ async def update(self, update_children: bool = True) -> None: if update_children: for plug in self.children: + if TYPE_CHECKING: + assert isinstance(plug, IotStripPlug) await plug._update() if not self.features: diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 7c9342c9d..5fdbf014d 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -207,6 +207,8 @@ async def set_state(self, state: LightState) -> dict: # iot protocol Dimmers and smart protocol devices do not support # brightness of 0 so 0 will turn off all devices for consistency if (bulb := self._get_bulb_device()) is None: # Dimmer + if TYPE_CHECKING: + assert isinstance(self._device, IotDimmer) if state.brightness == 0 or state.light_on is False: return await self._device.turn_off(transition=state.transition) elif state.brightness: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 07c8c154e..bd0ea7c5c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -28,6 +28,8 @@ ) from .smartmodule import SmartModule +if TYPE_CHECKING: + from .smartchilddevice import SmartChildDevice _LOGGER = logging.getLogger(__name__) @@ -196,6 +198,8 @@ async def update(self, update_children: bool = False) -> None: # child modules have access to their sysinfo. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): + if TYPE_CHECKING: + assert isinstance(child, SmartChildDevice) await child._update() # We can first initialize the features after the first update. diff --git a/tests/test_emeter.py b/tests/test_emeter.py index ad5ab190a..7eb16f8bd 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -16,6 +16,7 @@ from kasa.iot.modules.emeter import Emeter from kasa.smart import SmartDevice from kasa.smart.modules import Energy as SmartEnergyModule +from kasa.smart.smartmodule import SmartModule from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -192,6 +193,7 @@ async def test_supported(dev: Device): pytest.skip(f"Energy module not supported for {dev}.") energy_module = dev.modules.get(Module.Energy) assert energy_module + if isinstance(dev, IotDevice): info = ( dev._last_update @@ -210,6 +212,7 @@ async def test_supported(dev: Device): ) assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True else: + assert isinstance(energy_module, SmartModule) assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False if energy_module.supported_version < 2: