From ac3718c64d97c10f20ee79e6fc39acdfbe422922 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Wed, 26 Jun 2024 14:57:29 +0100 Subject: [PATCH 1/8] Expose fan_speed_level feature to allow accessing range values from the module api --- kasa/interfaces/fan.py | 6 ++++++ kasa/smart/modules/fan.py | 5 +++++ kasa/tests/smart/modules/test_fan.py | 6 ++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 89d8d82be..447a92b1a 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,12 +4,18 @@ from abc import ABC, abstractmethod +from ..feature import Feature from ..module import Module class Fan(Module, ABC): """Interface for a Fan.""" + @property + @abstractmethod + def fan_speed_level_feature(self) -> Feature: + """Return fan speed level feature.""" + @property @abstractmethod def fan_speed_level(self) -> int: diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 153f9c8f9..302283cd8 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -52,6 +52,11 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} + @property + def fan_speed_level_feature(self) -> Feature: + """Return fan speed level feature.""" + return self._module_features["fan_speed_level"] + @property def fan_speed_level(self) -> int: """Return fan speed level.""" diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index ee04015fa..7bb230f9c 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -76,8 +76,10 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on + max_level = fan.fan_speed_level_feature.maximum_value + min_level = fan.fan_speed_level_feature.minimum_value with pytest.raises(ValueError): - await fan.set_fan_speed_level(-1) + await fan.set_fan_speed_level(min_level - 1) with pytest.raises(ValueError): - await fan.set_fan_speed_level(5) + await fan.set_fan_speed_level(max_level - 5) From 1ce05e5db028fef1ed96b56d274c453a4b2d41af Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:55:16 +0100 Subject: [PATCH 2/8] Use typing.Annotated at runtime to get feature from a property --- kasa/feature.py | 37 ++++++++++++++++++++++- kasa/interfaces/fan.py | 6 ---- kasa/module.py | 44 +++++++++++++++++++++++++++- kasa/smart/modules/fan.py | 13 ++++---- kasa/tests/smart/modules/test_fan.py | 10 +++++-- 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index e20a926de..613054d2b 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -71,13 +71,25 @@ from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, get_type_hints + +from .exceptions import KasaException if TYPE_CHECKING: from .device import Device _LOGGER = logging.getLogger(__name__) +FeaturePropertyKind: TypeAlias = Literal["getter", "setter"] + + +@dataclass +class FeatureProperty: + """Class for annotating properties bound to feature.""" + + id: str + kind: FeaturePropertyKind = "getter" + @dataclass class Feature: @@ -111,6 +123,7 @@ class Type(Enum): Choice = Type.Choice DEFAULT_MAX = 2**16 # Arbitrary max + ANNOTATED_PROPERTY = "annotated_property" class Category(Enum): """Category hint to allow feature grouping.""" @@ -167,6 +180,10 @@ def __post_init__(self): # Populate minimum & maximum values, if range_getter is given self._container = self.container if self.container is not None else self.device + # get the annotation attribute_getter + if self.attribute_getter == self.ANNOTATED_PROPERTY: + self.attribute_getter = self._get_annotation_property() + # Set the category, if unset if self.category is Feature.Category.Unset: if self.attribute_setter: @@ -197,6 +214,24 @@ def _get_property_value(self, getter): return getter() raise ValueError("Invalid getter: %s", getter) # pragma: no cover + def _get_annotation_property(self, kind: FeaturePropertyKind = "getter"): + props = [ + prop + for p in dir(self.container.__class__) + if (prop := getattr(self.container.__class__, p)) + and isinstance(prop, property) + ] + for prop in props: + hints = get_type_hints(prop.fget, include_extras=True) + if (return_hints := hints.get("return")) and hasattr( + return_hints, "__metadata__" + ): + metadata = return_hints.__metadata__ + for meta in metadata: + if isinstance(meta, FeatureProperty) and meta.id == self.id: + return prop.fget if kind == "getter" else prop.fset + raise KasaException(f"Unable to find feature property: {self.id}") + @property def choices(self) -> list[str] | None: """List of choices.""" diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 447a92b1a..89d8d82be 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,18 +4,12 @@ from abc import ABC, abstractmethod -from ..feature import Feature from ..module import Module class Fan(Module, ABC): """Interface for a Fan.""" - @property - @abstractmethod - def fan_speed_level_feature(self) -> Feature: - """Return fan speed level feature.""" - @property @abstractmethod def fan_speed_level(self) -> int: diff --git a/kasa/module.py b/kasa/module.py index 68f5170d2..fa64186ca 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -45,11 +45,13 @@ from typing import ( TYPE_CHECKING, Final, + Self, TypeVar, + get_type_hints, ) from .exceptions import KasaException -from .feature import Feature +from .feature import Feature, FeatureProperty from .modulemapping import ModuleName if TYPE_CHECKING: @@ -61,6 +63,8 @@ _LOGGER = logging.getLogger(__name__) ModuleT = TypeVar("ModuleT", bound="Module") +_R = TypeVar("_R") +_T = TypeVar("_T") class Module(ABC): @@ -132,6 +136,44 @@ def __init__(self, device: Device, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} + self._bound_feature_ids: dict[property, str] = {} + + def _bound_feature_id(self, attr: property) -> str | None: + """Get bound feature for module or none if not supported.""" + if attr in self._bound_feature_ids: + return self._bound_feature_ids[attr] + if isinstance(attr, property): + hints = get_type_hints(attr.fget, include_extras=True) + if (return_hints := hints.get("return")) and hasattr( + return_hints, "__metadata__" + ): + metadata = hints["return"].__metadata__ + for meta in metadata: + if isinstance(meta, FeatureProperty): + self._bound_feature_ids[attr] = meta.id + return meta.id + return None + + def is_bound_feature(self, attr: property) -> bool: + """Return True if property is bound to a feature.""" + return bool(self._bound_feature_id(attr)) + + def has_bound_feature(self, attr: property) -> bool: + """Return True if the bound property feature is supported.""" + if id := self._bound_feature_id(attr): + return id in self._module_features + raise KasaException("") + + def get_bound_feature(self, attr: property) -> Feature | None: + """Get Feature for a bound property or None if not supported.""" + if id := self._bound_feature_id(attr): + return self._module_features.get(id) + raise KasaException("") + + @property + def module(self) -> type[Self]: + """Get the module type.""" + return self.__class__ @abstractmethod def query(self): diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 8caeadaef..dbc909ce3 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -2,7 +2,9 @@ from __future__ import annotations -from ...feature import Feature +from typing import Annotated + +from ...feature import Feature, FeatureProperty from ...interfaces.fan import Fan as FanInterface from ..smartmodule import SmartModule @@ -20,7 +22,7 @@ def _initialize_features(self): id="fan_speed_level", name="Fan speed level", container=self, - attribute_getter="fan_speed_level", + attribute_getter=Feature.ANNOTATED_PROPERTY, attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, @@ -46,12 +48,7 @@ def query(self) -> dict: return {} @property - def fan_speed_level_feature(self) -> Feature: - """Return fan speed level feature.""" - return self._module_features["fan_speed_level"] - - @property - def fan_speed_level(self) -> int: + def fan_speed_level(self) -> Annotated[int, FeatureProperty("fan_speed_level")]: """Return fan speed level.""" return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index db400caf6..a7063238a 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -76,8 +76,14 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on - max_level = fan.fan_speed_level_feature.maximum_value - min_level = fan.fan_speed_level_feature.minimum_value + assert fan.is_bound_feature(fan.module.fan_speed_level) + assert fan.has_bound_feature(fan.module.fan_speed_level) + fan_speed_level_feature = fan.get_bound_feature(fan.module.fan_speed_level) + assert fan_speed_level_feature + assert fan_speed_level_feature.value == 0 + + max_level = fan_speed_level_feature.maximum_value + min_level = fan_speed_level_feature.minimum_value with pytest.raises(ValueError, match="Invalid level"): await fan.set_fan_speed_level(min_level - 1) From 1f5b382e8a8a94e94b692f48c5d744f479a7059d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:05:33 +0100 Subject: [PATCH 3/8] Import TypeAlias and Self from typing_extensions for python3.9 and 3.10 --- kasa/feature.py | 4 +++- kasa/module.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 613054d2b..bd64f8e97 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -71,7 +71,9 @@ from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, get_type_hints +from typing import TYPE_CHECKING, Any, Callable, Literal, get_type_hints + +from typing_extensions import TypeAlias from .exceptions import KasaException diff --git a/kasa/module.py b/kasa/module.py index fa64186ca..82395bf00 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -45,11 +45,12 @@ from typing import ( TYPE_CHECKING, Final, - Self, TypeVar, get_type_hints, ) +from typing_extensions import Self + from .exceptions import KasaException from .feature import Feature, FeatureProperty from .modulemapping import ModuleName From fe320ff47fe89804440e1d7466e60a70ad24c92c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:21:40 +0100 Subject: [PATCH 4/8] Address review suggestions --- kasa/feature.py | 3 +-- kasa/module.py | 34 +++++++++++++--------------- kasa/smart/modules/fan.py | 1 - kasa/tests/smart/modules/test_fan.py | 6 ++--- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index bd64f8e97..74cb2b9df 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -125,7 +125,6 @@ class Type(Enum): Choice = Type.Choice DEFAULT_MAX = 2**16 # Arbitrary max - ANNOTATED_PROPERTY = "annotated_property" class Category(Enum): """Category hint to allow feature grouping.""" @@ -183,7 +182,7 @@ def __post_init__(self): self._container = self.container if self.container is not None else self.device # get the annotation attribute_getter - if self.attribute_getter == self.ANNOTATED_PROPERTY: + if self.attribute_getter is None and self.type is not Feature.Type.Action: self.attribute_getter = self._get_annotation_property() # Set the category, if unset diff --git a/kasa/module.py b/kasa/module.py index 82395bf00..ba9582a33 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -49,8 +49,6 @@ get_type_hints, ) -from typing_extensions import Self - from .exceptions import KasaException from .feature import Feature, FeatureProperty from .modulemapping import ModuleName @@ -137,10 +135,15 @@ def __init__(self, device: Device, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} - self._bound_feature_ids: dict[property, str] = {} + self._bound_feature_ids: dict[str, str] = {} - def _bound_feature_id(self, attr: property) -> str | None: + def _bound_feature_id(self, property_name: str) -> str | None: """Get bound feature for module or none if not supported.""" + if not hasattr(self.__class__, property_name) or ( + (attr := getattr(self.__class__, property_name)) + and not isinstance(getattr(self.__class__, property_name), property) + ): + raise KasaException(f"{property_name} is not a valid property") if attr in self._bound_feature_ids: return self._bound_feature_ids[attr] if isinstance(attr, property): @@ -151,30 +154,25 @@ def _bound_feature_id(self, attr: property) -> str | None: metadata = hints["return"].__metadata__ for meta in metadata: if isinstance(meta, FeatureProperty): - self._bound_feature_ids[attr] = meta.id + self._bound_feature_ids[property_name] = meta.id return meta.id return None - def is_bound_feature(self, attr: property) -> bool: + def is_bound_feature(self, property_name: str) -> bool: """Return True if property is bound to a feature.""" - return bool(self._bound_feature_id(attr)) + return bool(self._bound_feature_id(property_name)) - def has_bound_feature(self, attr: property) -> bool: + def has_bound_feature(self, property_name: str) -> bool: """Return True if the bound property feature is supported.""" - if id := self._bound_feature_id(attr): + if id := self._bound_feature_id(property_name): return id in self._module_features - raise KasaException("") + raise KasaException(f"{property_name} is not bound to a feature") - def get_bound_feature(self, attr: property) -> Feature | None: + def get_bound_feature(self, property_name: str) -> Feature | None: """Get Feature for a bound property or None if not supported.""" - if id := self._bound_feature_id(attr): + if id := self._bound_feature_id(property_name): return self._module_features.get(id) - raise KasaException("") - - @property - def module(self) -> type[Self]: - """Get the module type.""" - return self.__class__ + raise KasaException(f"{property_name} is not bound to a feature") @abstractmethod def query(self): diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index dbc909ce3..af34f71f2 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -22,7 +22,6 @@ def _initialize_features(self): id="fan_speed_level", name="Fan speed level", container=self, - attribute_getter=Feature.ANNOTATED_PROPERTY, attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index a7063238a..5ef3bc039 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -76,9 +76,9 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on - assert fan.is_bound_feature(fan.module.fan_speed_level) - assert fan.has_bound_feature(fan.module.fan_speed_level) - fan_speed_level_feature = fan.get_bound_feature(fan.module.fan_speed_level) + assert fan.is_bound_feature("fan_speed_level") + assert fan.has_bound_feature("fan_speed_level") + fan_speed_level_feature = fan.get_bound_feature("fan_speed_level") assert fan_speed_level_feature assert fan_speed_level_feature.value == 0 From 98b324304c12a870082d44afa42c80f873cab4d4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 12:07:45 +0000 Subject: [PATCH 5/8] Simplify logic for checking feature and do not annotate both ways --- kasa/feature.py | 38 +----------- kasa/module.py | 102 +++++++++++++++++++------------- kasa/smart/modules/fan.py | 14 +++-- tests/smart/modules/test_fan.py | 36 ++++++++--- 4 files changed, 100 insertions(+), 90 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index b246b1155..d747338da 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -72,25 +72,13 @@ from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, get_type_hints - -from .exceptions import KasaException +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .device import Device _LOGGER = logging.getLogger(__name__) -FeaturePropertyKind: TypeAlias = Literal["getter", "setter"] - - -@dataclass -class FeatureProperty: - """Class for annotating properties bound to feature.""" - - id: str - kind: FeaturePropertyKind = "getter" - @dataclass class Feature: @@ -180,10 +168,6 @@ def __post_init__(self) -> None: # Populate minimum & maximum values, if range_getter is given self._container = self.container if self.container is not None else self.device - # get the annotation attribute_getter - if self.attribute_getter is None and self.type is not Feature.Type.Action: - self.attribute_getter = self._get_annotation_property() - # Set the category, if unset if self.category is Feature.Category.Unset: if self.attribute_setter: @@ -214,26 +198,6 @@ def _get_property_value(self, getter: str | Callable | None) -> Any: return getter() raise ValueError("Invalid getter: %s", getter) # pragma: no cover - def _get_annotation_property( - self, kind: FeaturePropertyKind = "getter" - ) -> Callable: - props = [ - prop - for p in dir(self.container.__class__) - if (prop := getattr(self.container.__class__, p)) - and isinstance(prop, property) - ] - for prop in props: - hints = get_type_hints(prop.fget, include_extras=True) - if (return_hints := hints.get("return")) and hasattr( - return_hints, "__metadata__" - ): - metadata = return_hints.__metadata__ - for meta in metadata: - if isinstance(meta, FeatureProperty) and meta.id == self.id: - return prop.fget if kind == "getter" else prop.fset - raise KasaException(f"Unable to find feature property: {self.id}") - @property def choices(self) -> list[str] | None: """List of choices.""" diff --git a/kasa/module.py b/kasa/module.py index 1ecf4f95d..88c226c18 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -42,6 +42,8 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Callable +from functools import cache from typing import ( TYPE_CHECKING, Final, @@ -50,7 +52,7 @@ ) from .exceptions import KasaException -from .feature import Feature, FeatureProperty +from .feature import Feature from .modulemapping import ModuleName if TYPE_CHECKING: @@ -63,8 +65,10 @@ _LOGGER = logging.getLogger(__name__) ModuleT = TypeVar("ModuleT", bound="Module") -_R = TypeVar("_R") -_T = TypeVar("_T") + + +class FeatureAttribute: + """Class for annotating attributes bound to feature.""" class Module(ABC): @@ -142,44 +146,14 @@ def __init__(self, device: Device, module: str) -> None: self._device = device self._module = module self._module_features: dict[str, Feature] = {} - self._bound_feature_ids: dict[str, str] = {} - - def _bound_feature_id(self, property_name: str) -> str | None: - """Get bound feature for module or none if not supported.""" - if not hasattr(self.__class__, property_name) or ( - (attr := getattr(self.__class__, property_name)) - and not isinstance(getattr(self.__class__, property_name), property) - ): - raise KasaException(f"{property_name} is not a valid property") - if attr in self._bound_feature_ids: - return self._bound_feature_ids[attr] - if isinstance(attr, property): - hints = get_type_hints(attr.fget, include_extras=True) - if (return_hints := hints.get("return")) and hasattr( - return_hints, "__metadata__" - ): - metadata = hints["return"].__metadata__ - for meta in metadata: - if isinstance(meta, FeatureProperty): - self._bound_feature_ids[property_name] = meta.id - return meta.id - return None - - def is_bound_feature(self, property_name: str) -> bool: - """Return True if property is bound to a feature.""" - return bool(self._bound_feature_id(property_name)) - - def has_bound_feature(self, property_name: str) -> bool: - """Return True if the bound property feature is supported.""" - if id := self._bound_feature_id(property_name): - return id in self._module_features - raise KasaException(f"{property_name} is not bound to a feature") - - def get_bound_feature(self, property_name: str) -> Feature | None: - """Get Feature for a bound property or None if not supported.""" - if id := self._bound_feature_id(property_name): - return self._module_features.get(id) - raise KasaException(f"{property_name} is not bound to a feature") + + def has_feature(self, attribute: str | property | Callable) -> bool: + """Return True if the module attribute feature is supported.""" + return bool(self.get_feature(attribute)) + + def get_feature(self, attribute: str | property | Callable) -> Feature | None: + """Get Feature for a module attribute or None if not supported.""" + return _get_bound_feature(self, attribute) @abstractmethod def query(self) -> dict: @@ -224,3 +198,49 @@ def __repr__(self) -> str: f"" ) + + +def _is_bound_feature(attribute: property | Callable) -> bool: + """Check if an attribute is bound to a feature with FeatureAttribute.""" + if isinstance(attribute, property): + hints = get_type_hints(attribute.fget, include_extras=True) + else: + hints = get_type_hints(attribute, include_extras=True) + if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"): + metadata = hints["return"].__metadata__ + for meta in metadata: + if isinstance(meta, FeatureAttribute): + return True + return False + + +@cache +def _get_bound_feature( + module: Module, attribute: str | property | Callable +) -> Feature | None: + """Get Feature for a bound property or None if not supported.""" + if not isinstance(attribute, str): + attribute_name = attribute.__name__ + attribute_callable = attribute + else: + if TYPE_CHECKING: + assert isinstance(attribute, str) + attribute_name = attribute + attribute_callable = getattr(module.__class__, attribute, None) # type: ignore[assignment] + if not attribute_callable: + raise KasaException( + f"No attribute named {attribute_name} in " + f"module {module.__class__.__name__}" + ) + if not _is_bound_feature(attribute_callable): + raise KasaException( + f"Attribute {attribute_name} of module {module.__class__.__name__}" + " is not bound to a feature" + ) + check = {attribute_name, attribute_callable} + for feature in module._module_features.values(): + if (getter := feature.attribute_getter) and getter in check: + return feature + if (setter := feature.attribute_setter) and setter in check: + return feature + return None diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index be97dc2c9..6443cbacb 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -4,8 +4,9 @@ from typing import Annotated -from ...feature import Feature, FeatureProperty +from ...feature import Feature from ...interfaces.fan import Fan as FanInterface +from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -22,6 +23,7 @@ def _initialize_features(self) -> None: id="fan_speed_level", name="Fan speed level", container=self, + attribute_getter="fan_speed_level", attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, @@ -47,11 +49,13 @@ def query(self) -> dict: return {} @property - def fan_speed_level(self) -> Annotated[int, FeatureProperty("fan_speed_level")]: + def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]: """Return fan speed level.""" return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] - async def set_fan_speed_level(self, level: int) -> dict: + async def set_fan_speed_level( + self, level: int + ) -> Annotated[dict, FeatureAttribute()]: """Set fan speed level, 0 for off, 1-4 for on.""" if level < 0 or level > 4: raise ValueError("Invalid level, should be in range 0-4.") @@ -62,11 +66,11 @@ async def set_fan_speed_level(self, level: int) -> dict: ) @property - def sleep_mode(self) -> bool: + def sleep_mode(self) -> Annotated[bool, FeatureAttribute()]: """Return sleep mode status.""" return self.data["fan_sleep_mode_on"] - async def set_sleep_mode(self, on: bool) -> dict: + async def set_sleep_mode(self, on: bool) -> Annotated[dict, FeatureAttribute()]: """Set sleep mode.""" return await self.call("set_device_info", {"fan_sleep_mode_on": on}) diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py index 13434c1bf..032409a97 100644 --- a/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -1,8 +1,9 @@ import pytest from pytest_mock import MockerFixture -from kasa import Module +from kasa import KasaException, Module from kasa.smart import SmartDevice +from kasa.smart.modules import Fan from ...device_fixtures import get_parent_and_child_modules, parametrize @@ -77,12 +78,7 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on - assert fan.is_bound_feature("fan_speed_level") - assert fan.has_bound_feature("fan_speed_level") - fan_speed_level_feature = fan.get_bound_feature("fan_speed_level") - assert fan_speed_level_feature - assert fan_speed_level_feature.value == 0 - + fan_speed_level_feature = fan._module_features["fan_speed_level"] max_level = fan_speed_level_feature.maximum_value min_level = fan_speed_level_feature.minimum_value with pytest.raises(ValueError, match="Invalid level"): @@ -90,3 +86,29 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): with pytest.raises(ValueError, match="Invalid level"): await fan.set_fan_speed_level(max_level - 5) + + +@fan +async def test_fan_features(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed on device interface.""" + assert isinstance(dev, SmartDevice) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) + assert fan + expected_feature = fan._module_features["fan_speed_level"] + fan_speed_level_feature = fan.get_feature(Fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature(fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature(Fan.fan_speed_level) + assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature("fan_speed_level") + assert expected_feature == fan_speed_level_feature + assert fan.has_feature(Fan.fan_speed_level) + + msg = "Attribute _check_supported of module Fan is not bound to a feature" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature(fan._check_supported) + + msg = "No attribute named foobar in module Fan" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature("foobar") From 8ee6d060c5825684ad0c8213f94ffc42b6444711 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:52:18 +0000 Subject: [PATCH 6/8] Fix property name for Feature | None: """Get Feature for a bound property or None if not supported.""" if not isinstance(attribute, str): - attribute_name = attribute.__name__ + if isinstance(attribute, property): + # Properties have __name__ in 3.13 so this could be simplified + # when only 3.13 supported + attribute_name = attribute.fget.__name__ # type: ignore[union-attr] + else: + attribute_name = attribute.__name__ attribute_callable = attribute else: if TYPE_CHECKING: From c64b50b29aeffc62ff037501e12687f894b73784 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:42:38 +0000 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Teemu R. --- kasa/module.py | 12 +++++++++--- tests/smart/modules/test_fan.py | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index 1f6f38b5b..1851a260a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -202,15 +202,17 @@ def __repr__(self) -> str: def _is_bound_feature(attribute: property | Callable) -> bool: """Check if an attribute is bound to a feature with FeatureAttribute.""" - if isinstance(attribute, property): - hints = get_type_hints(attribute.fget, include_extras=True) - else: + if isinstance(attribute, Callable): hints = get_type_hints(attribute, include_extras=True) + else: + hints = get_type_hints(attribute.fget, include_extras=True) + if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"): metadata = hints["return"].__metadata__ for meta in metadata: if isinstance(meta, FeatureAttribute): return True + return False @@ -237,15 +239,19 @@ def _get_bound_feature( f"No attribute named {attribute_name} in " f"module {module.__class__.__name__}" ) + if not _is_bound_feature(attribute_callable): raise KasaException( f"Attribute {attribute_name} of module {module.__class__.__name__}" " is not bound to a feature" ) + check = {attribute_name, attribute_callable} for feature in module._module_features.values(): if (getter := feature.attribute_getter) and getter in check: return feature + if (setter := feature.attribute_setter) and setter in check: return feature + return None diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py index 032409a97..9a6878e5b 100644 --- a/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -95,14 +95,19 @@ async def test_fan_features(dev: SmartDevice, mocker: MockerFixture): fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan expected_feature = fan._module_features["fan_speed_level"] + fan_speed_level_feature = fan.get_feature(Fan.set_fan_speed_level) assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature(fan.set_fan_speed_level) assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature(Fan.fan_speed_level) assert expected_feature == fan_speed_level_feature + fan_speed_level_feature = fan.get_feature("fan_speed_level") assert expected_feature == fan_speed_level_feature + assert fan.has_feature(Fan.fan_speed_level) msg = "Attribute _check_supported of module Fan is not bound to a feature" From 8c174c3069d81b7baeb609a31d893c2ef3913ea8 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:48:37 +0000 Subject: [PATCH 8/8] Revert change isinstance check to Callable --- kasa/module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index 1851a260a..ccd22d4e0 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -202,10 +202,10 @@ def __repr__(self) -> str: def _is_bound_feature(attribute: property | Callable) -> bool: """Check if an attribute is bound to a feature with FeatureAttribute.""" - if isinstance(attribute, Callable): - hints = get_type_hints(attribute, include_extras=True) - else: + if isinstance(attribute, property): hints = get_type_hints(attribute.fget, include_extras=True) + else: + hints = get_type_hints(attribute, include_extras=True) if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"): metadata = hints["return"].__metadata__