8000 Allow getting Annotated features from modules (#1018) · python-kasa/python-kasa@37cc4da · GitHub
[go: up one dir, main page]

Skip to content

Commit 37cc4da

Browse files
sdb9696rytilahti
andauthored
Allow getting Annotated features from modules (#1018)
Co-authored-by: Teemu R. <tpr@iki.fi>
1 parent cae9dec commit 37cc4da

File tree

3 files changed

+119
-7
lines changed

3 files changed

+119
-7
lines changed

kasa/module.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,13 @@
4242

4343
import logging
4444
from abc import ABC, abstractmethod
45+
from collections.abc import Callable
46+
from functools import cache
4547
from typing import (
4648
TYPE_CHECKING,
4749
Final,
4850
TypeVar,
51+
get_type_hints,
4952
)
5053

5154
from .exceptions import KasaException
@@ -64,6 +67,10 @@
6467
ModuleT = TypeVar("ModuleT", bound="Module")
6568

6669

70+
class FeatureAttribute:
71+
"""Class for annotating attributes bound to feature."""
72+
73+
6774
class Module(ABC):
6875
"""Base class implemention for all modules.
6976
@@ -140,6 +147,14 @@ def __init__(self, device: Device, module: str) -> None:
140147
self._module = module
141148
self._module_features: dict[str, Feature] = {}
142149

150+
def has_feature(self, attribute: str | property | Callable) -> bool:
151+
"""Return True if the module attribute feature is supported."""
152+
return bool(self.get_feature(attribute))
153+
154+
def get_feature(self, attribute: str | property | Callable) -> Feature | None:
155+
"""Get Feature for a module attribute or None if not supported."""
156+
return _get_bound_feature(self, attribute)
157+
143158
@abstractmethod
144159
def query(self) -> dict:
145160
"""Query to execute during the update cycle.
@@ -183,3 +198,60 @@ def __repr__(self) -> str:
183198
f"<Module {self.__class__.__name__} ({self._module})"
184199
f" for {self._device.host}>"
185200
)
201+
202+
203+
def _is_bound_feature(attribute: property | Callable) -> bool:
204+
"""Check if an attribute is bound to a feature with FeatureAttribute."""
205+
if isinstance(attribute, property):
206+
hints = get_type_hints(attribute.fget, include_extras=True)
207+
else:
208+
hints = get_type_hints(attribute, include_extras=True)
209+
210+
if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"):
211+
metadata = hints["return"].__metadata__
212+
for meta in metadata:
213+
if isinstance(meta, FeatureAttribute):
214+
return True
215+
216+
return False
217+
218+
219+
@cache
220+
def _get_bound_feature(
221+
module: Module, attribute: str | property | Callable
222+
) -> Feature | None:
223+
"""Get Feature for a bound property or None if not supported."""
224+
if not isinstance(attribute, str):
225+
if isinstance(attribute, property):
226+
# Properties have __name__ in 3.13 so this could be simplified
227+
# when only 3.13 supported
228+
attribute_name = attribute.fget.__name__ # type: ignore[union-attr]
229+
else:
230+
attribute_name = attribute.__name__
231+
attribute_callable = attribute
232+
else:
233+
if TYPE_CHECKING:
234+
assert isinstance(attribute, str)
235+
attribute_name = attribute
236+
attribute_callable = getattr(module.__class__, attribute, None) # type: ignore[assignment]
237+
if not attribute_callable:
238+
raise KasaException(
239+
f"No attribute named {attribute_name} in "
240+
f"module {module.__class__.__name__}"
241+
)
242+
243+
if not _is_bound_feature(attribute_callable):
244+
raise KasaException(
245+
f"Attribute {attribute_name} of module {module.__class__.__name__}"
246+
" is not bound to a feature"
247+
)
248+
249+
check = {attribute_name, attribute_callable}
250+
for feature in module._module_features.values():
251+
if (getter := feature.attribute_getter) and getter in check:
252+
return feature
253+
254+
if (setter := feature.attribute_setter) and setter in check:
255+
return feature
256+
257+
return None

kasa/smart/modules/fan.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
from typing import Annotated
6+
57
from ...feature import Feature
68
from ...interfaces.fan import Fan as FanInterface
9+
from ...module import FeatureAttribute
710
from ..smartmodule import SmartModule
811

912

@@ -46,11 +49,13 @@ def query(self) -> dict:
4649
return {}
4750

4851
@property
49-
def fan_speed_level(self) -> int:
52+
def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]:
5053
"""Return fan speed level."""
5154
return 0 if self.data["device_on"] is False else self.data["fan_speed_level"]
5255

53-
async def set_fan_speed_level(self, level: int) -> dict:
56+
async def set_fan_speed_level(
57+
self, level: int
58+
) -> Annotated[dict, FeatureAttribute()]:
5459
"""Set fan speed level, 0 for off, 1-4 for on."""
5560
if level < 0 or level > 4:
5661
raise ValueError("Invalid level, should be in range 0-4.")
@@ -61,11 +66,11 @@ async def set_fan_speed_level(self, level: int) -> dict:
6166
)
6267

6368
@property
64-
def sleep_mode(self) -> bool:
69+
def sleep_mode(self) -> Annotated[bool, FeatureAttribute()]:
6570
"""Return sleep mode status."""
6671
return self.data["fan_sleep_mode_on"]
6772

68-
async def set_sleep_mode(self, on: bool) -> dict:
73+
async def set_sleep_mode(self, on: bool) -> Annotated[dict, FeatureAttribute()]:
6974
"""Set sleep mode."""
7075
return await self.call("set_device_info", {"fan_sleep_mode_on": on})
7176

tests/smart/modules/test_fan.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import pytest
22
from pytest_mock import MockerFixture
33

4-
from kasa import Module
4+
from kasa import KasaException, Module
55
from kasa.smart import SmartDevice
6+
from kasa.smart.modules import Fan
67

78
from ...device_fixtures import get_parent_and_child_modules, parametrize
89

@@ -77,8 +78,42 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture):
7778
await dev.update()
7879
assert not device.is_on
7980

81+
fan_speed_level_feature = fan._module_features["fan_speed_level"]
82+
max_level = fan_speed_level_feature.maximum_value
83+
min_level = fan_speed_level_feature.minimum_value
8084
with pytest.raises(ValueError, match="Invalid level"):
81-
await fan.set_fan_speed_level(-1)
85+
await fan.set_fan_speed_level(min_level - 1)
8286

8387
with pytest.raises(ValueError, match="Invalid level"):
84-
await fan.set_fan_speed_level(5)
88+
await fan.set_fan_speed_level(max_level - 5)
89+
90+
91+
@fan
92+
async def test_fan_features(dev: SmartDevice, mocker: MockerFixture):
93+
"""Test fan speed on device interface."""
94+
assert isinstance(dev, SmartDevice)
95+
fan = next(get_parent_and_child_modules(dev, Module.Fan))
96+
assert fan
97+
expected_feature = fan._module_features["fan_speed_level"]
98+
99+
fan_speed_level_feature = fan.get_feature(Fan.set_fan_speed_level)
100+
assert expected_feature == fan_speed_level_feature
101+
102+
fan_speed_level_feature = fan.get_feature(fan.set_fan_speed_level)
103+
assert expected_feature == fan_speed_level_feature
104+
105+
fan_speed_level_feature = fan.get_feature(Fan.fan_speed_level)
106+
assert expected_feature == fan_speed_level_feature
107+
108+
fan_speed_level_feature = fan.get_feature("fan_speed_level")
109+
assert expected_feature == fan_speed_level_feature
110+
111+
assert fan.has_feature(Fan.fan_speed_level)
112+
113+
msg = "Attribute _check_supported of module Fan is not bound to a feature"
114+
with pytest.raises(KasaException, match=msg):
115+
assert fan.has_feature(fan._check_supported)
116+
117+
msg = "No attribute named foobar in module Fan"
118+
with pytest.raises(KasaException, match=msg):
119+
assert fan.has_feature("foobar")

0 commit comments

Comments
 (0)
0