8000 Use typing.Annotated at runtime to get feature from a property · python-kasa/python-kasa@1ce05e5 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1ce05e5

Browse files
committed
Use typing.Annotated at runtime to get feature from a property
1 parent 56af352 commit 1ce05e5

File tree

5 files changed

+92
-18
lines changed

5 files changed

+92
-18
lines changed

kasa/feature.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,25 @@
7171
from dataclasses import dataclass
7272
from enum import Enum, auto
7373
from functools import cached_property
74-
from typing import TYPE_CHECKING, Any, Callable
74+
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, get_type_hints
75+
76+
from .exceptions import KasaException
7577

7678
if TYPE_CHECKING:
7779
from .device import Device
7880

7981
_LOGGER = logging.getLogger(__name__)
8082

83+
FeaturePropertyKind: TypeAlias = Literal["getter", "setter"]
84+
85+
86+
@dataclass
87+
class FeatureProperty:
88+
"""Class for annotating properties bound to feature."""
89+
90+
id: str
91+
kind: FeaturePropertyKind = "getter"
92+
8193

8294
@dataclass
8395
class Feature:
@@ -111,6 +123,7 @@ class Type(Enum):
111123
Choice = Type.Choice
112124

113125
DEFAULT_MAX = 2**16 # Arbitrary max
126+
ANNOTATED_PROPERTY = "annotated_property"
114127

115128
class Category(Enum):
116129
"""Category hint to allow feature grouping."""
@@ -167,6 +180,10 @@ def __post_init__(self):
167180
# Populate minimum & maximum values, if range_getter is given
168181
self._container = self.container if self.container is not None else self.device
169182

183+
# get the annotation attribute_getter
184+
if self.attribute_getter == self.ANNOTATED_PROPERTY:
185+
self.attribute_getter = self._get_annotation_property()
186+
170187
# Set the category, if unset
171188
if self.category is Feature.Category.Unset:
172189
if self.attribute_setter:
@@ -197,6 +214,24 @@ def _get_property_value(self, getter):
197214
return getter()
198215
raise ValueError("Invalid getter: %s", getter) # pragma: no cover
199216

217+
def _get_annotation_property(self, kind: FeaturePropertyKind = "getter"):
218+
props = [
219+
prop
220+
for p in dir(self.container.__class__)
221+
if (prop := getattr(self.container.__class__, p))
222+
and isinstance(prop, property)
223+
]
224+
for prop in props:
225+
hints = get_type_hints(prop.fget, include_extras=True)
226+
if (return_hints := hints.get("return")) and hasattr(
227+
return_hints, "__metadata__"
228+
):
229+
metadata = return_hints.__metadata__
230+
for meta in metadata:
231+
if isinstance(meta, FeatureProperty) and meta.id == self.id:
232+
return prop.fget if kind == "getter" else prop.fset
233+
raise KasaException(f"Unable to find feature property: {self.id}")
234+
200235
@property
201236
def choices(self) -> list[str] | None:
202237
"""List of choices."""

kasa/interfaces/fan.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,12 @@
44

55
from abc import ABC, abstractmethod
66

7-
from ..feature import Feature
87
from ..module import Module
98

109

1110
class Fan(Module, ABC):
1211
"""Interface for a Fan."""
1312

14-
@property
15-
@abstractmethod
16-
def fan_speed_level_feature(self) -> Feature:
17-
"""Return fan speed level feature."""
18-
1913
@property
2014
@abstractmethod
2115
def fan_speed_level(self) -> int:

kasa/module.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@
4545
from typing import (
4646
TYPE_CHECKING,
4747
Final,
48+
Self,
4849
TypeVar,
50+
get_type_hints,
4951
)
5052

5153
from .exceptions import KasaException
52-
from .feature import Feature
54+
from .feature import Feature, FeatureProperty
5355
from .modulemapping import ModuleName
5456

5557
if TYPE_CHECKING:
@@ -61,6 +63,8 @@
6163
_LOGGER = logging.getLogger(__name__)
6264

6365
ModuleT = TypeVar("ModuleT", bound="Module")
66+
_R = TypeVar("_R")
67+
_T = TypeVar("_T")
6468

6569

6670
class Module(ABC):
@@ -132,6 +136,44 @@ def __init__(self, device: Device, module: str):
132136
self._device = device
133137
self._module = module
134138
self._module_features: dict[str, Feature] = {}
139+
self._bound_feature_ids: dict[property, str] = {}
140+
141+
def _bound_feature_id(self, attr: property) -> str | None:
142+
"""Get bound feature for module or none if not supported."""
143+
if attr in self._bound_feature_ids:
144+
return self._bound_feature_ids[attr]
145+
if isinstance(attr, property):
146+
hints = get_type_hints(attr.fget, include_extras=True)
147+
if (return_hints := hints.get("return")) and hasattr(
148+
return_hints, "__metadata__"
149+
):
150+
metadata = hints["return"].__metadata__
151+
for meta in metadata:
152+
if isinstance(meta, FeatureProperty):
153+
self._bound_feature_ids[attr] = meta.id
154+
return meta.id
155+
return None
156+
157+
def is_bound_feature(self, attr: property) -> bool:
158+
"""Return True if property is bound to a feature."""
159+
return bool(self._bound_feature_id(attr))
160+
161+
def has_bound_feature(self, attr: property) -> bool:
162+
"""Return True if the bound property feature is supported."""
163+
if id := self._bound_feature_id(attr):
164+
return id in self._module_features
165+
raise KasaException("")
166+
167+
def get_bound_feature(self, attr: property) -> Feature | None:
168+
"""Get Feature for a bound property or None if not supported."""
169+
if id := self._bound_feature_id(attr):
170+
return self._module_features.get(id)
171+
raise KasaException("")
172+
173+
@property
174+
def module(self) -> type[Self]:
175+
"""Get the module type."""
176+
return self.__class__
135177

136178
@abstractmethod
137179
def query(self):

kasa/smart/modules/fan.py

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

33
from __future__ import annotations
44

5-
from ...feature import Feature
5+
from typing import Annotated
6+
7+
from ...feature import Feature, FeatureProperty
68
from ...interfaces.fan import Fan as FanInterface
79
from ..smartmodule import SmartModule
810

@@ -20,7 +22,7 @@ def _initialize_features(self):
2022
id="fan_speed_level",
2123
name="Fan speed level",
2224
container=self,
23-
attribute_getter="fan_speed_level",
25+
attribute_getter=Feature.ANNOTATED_PROPERTY,
2426
attribute_setter="set_fan_speed_level",
2527
icon="mdi:fan",
2628
type=Feature.Type.Number,
@@ -46,12 +48,7 @@ def query(self) -> dict:
4648
return {}
4749

4850
@property
49-
def fan_speed_level_feature(self) -> Feature:
50-
"""Return fan speed level feature."""
51-
return self._module_features["fan_speed_level"]
52-
53-
@property
54-
def fan_speed_level(self) -> int:
51+
def fan_speed_level(self) -> Annotated[int, FeatureProperty("fan_speed_level")]:
5552
"""Return fan speed level."""
5653
return 0 if self.data["device_on"] is False else self.data["fan_speed_level"]
5754

kasa/tests/smart/modules/test_fan.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,14 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture):
7676
await dev.update()
7777
assert not device.is_on
7878

79-
max_level = fan.fan_speed_level_feature.maximum_value
80-
min_level = fan.fan_speed_level_feature.minimum_value
79+
assert fan.is_bound_feature(fan.module.fan_speed_level)
80+
assert fan.has_bound_feature(fan.module.fan_speed_level)
81+
fan_speed_level_feature = fan.get_bound_feature(fan.module.fan_speed_level)
82+
assert fan_speed_level_feature
83+
assert fan_speed_level_feature.value == 0
84+
85+
max_level = fan_speed_level_feature.maximum_value
86+
min_level = fan_speed_level_feature.minimum_value
8187
with pytest.raises(ValueError, match="Invalid level"):
8288
await fan.set_fan_speed_level(min_level - 1)
8389

0 commit comments

Comments
 (0)
0