Skip to content

Commit 2aaee1b

Browse files
committed
Migrate to cattrs for centralized model structuring
Replace per-model converter functions (_flexible_init, _to_list, _to_optional, _to_optional_enum) with a single cattrs Converter that handles all dict-to-model structuring. Models become pure data classes with only type annotations and defaults. - Add cattrs dependency - Add pyoverkiz/converter.py with hooks for primitive unions, optional attrs classes, enums, and container types (States, CommandDefinitions) - Strip all converter infrastructure from models.py - Update client.py to use converter.structure() instead of Model(**data) - Update tests to use converter for API-like dict structuring
1 parent 8d7d472 commit 2aaee1b

File tree

7 files changed

+354
-400
lines changed

7 files changed

+354
-400
lines changed

pyoverkiz/client.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings
2323
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
2424
from pyoverkiz.const import SUPPORTED_SERVERS
25+
from pyoverkiz.converter import converter
2526
from pyoverkiz.enums import APIType, CommandMode, Server
2627
from pyoverkiz.exceptions import (
2728
ExecutionQueueFullError,
@@ -305,7 +306,7 @@ async def get_setup(self, refresh: bool = False) -> Setup:
305306

306307
response = await self._get("setup")
307308

308-
setup = Setup(**decamelize(response))
309+
setup = converter.structure(decamelize(response), Setup)
309310

310311
# Cache response
311312
self.setup = setup
@@ -343,7 +344,7 @@ async def get_devices(self, refresh: bool = False) -> list[Device]:
343344
return self.devices
344345

345346
response = await self._get("setup/devices")
346-
devices = [Device(**d) for d in decamelize(response)]
347+
devices = converter.structure(decamelize(response), list[Device])
347348

348349
# Cache response
349350
self.devices = devices
@@ -362,7 +363,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
362363
return self.gateways
363364

364365
response = await self._get("setup/gateways")
365-
gateways = [Gateway(**g) for g in decamelize(response)]
366+
gateways = converter.structure(decamelize(response), list[Gateway])
366367

367368
# Cache response
368369
self.gateways = gateways
@@ -375,7 +376,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
375376
async def get_execution_history(self) -> list[HistoryExecution]:
376377
"""List execution history."""
377378
response = await self._get("history/executions")
378-
return [HistoryExecution(**h) for h in decamelize(response)]
379+
return converter.structure(decamelize(response), list[HistoryExecution])
379380

380381
@retry_on_auth_error
381382
async def get_device_definition(self, deviceurl: str) -> JSON | None:
@@ -392,7 +393,7 @@ async def get_state(self, deviceurl: str) -> list[State]:
392393
response = await self._get(
393394
f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states"
394395
)
395-
return [State(**s) for s in decamelize(response)]
396+
return converter.structure(decamelize(response), list[State])
396397

397398
@retry_on_auth_error
398399
async def refresh_states(self) -> None:
@@ -436,7 +437,7 @@ async def fetch_events(self) -> list[Event]:
436437
"""
437438
await self._refresh_token_if_expired()
438439
response = await self._post(f"events/{self.event_listener_id}/fetch")
439-
return [Event(**e) for e in decamelize(response)]
440+
return converter.structure(decamelize(response), list[Event])
440441

441442
async def unregister_event_listener(self) -> None:
442443
"""Unregister an event listener.
@@ -451,13 +452,13 @@ async def unregister_event_listener(self) -> None:
451452
async def get_current_execution(self, exec_id: str) -> Execution:
452453
"""Get an action group execution currently running."""
453454
response = await self._get(f"exec/current/{exec_id}")
454-
return Execution(**decamelize(response))
455+
return converter.structure(decamelize(response), Execution)
455456

456457
@retry_on_auth_error
457458
async def get_current_executions(self) -> list[Execution]:
458459
"""Get all action groups executions currently running."""
459460
response = await self._get("exec/current")
460-
return [Execution(**e) for e in decamelize(response)]
461+
return converter.structure(decamelize(response), list[Execution])
461462

462463
@retry_on_auth_error
463464
async def get_api_version(self) -> str:
@@ -555,7 +556,7 @@ async def cancel_command(self, exec_id: str) -> None:
555556
async def get_action_groups(self) -> list[ActionGroup]:
556557
"""List the action groups (scenarios)."""
557558
response = await self._get("actionGroups")
558-
return [ActionGroup(**action_group) for action_group in decamelize(response)]
559+
return converter.structure(decamelize(response), list[ActionGroup])
559560

560561
@retry_on_auth_error
561562
async def get_places(self) -> Place:
@@ -570,7 +571,7 @@ async def get_places(self) -> Place:
570571
- `sub_places`: List of nested places within this location
571572
"""
572573
response = await self._get("setup/places")
573-
return Place(**decamelize(response))
574+
return converter.structure(decamelize(response), Place)
574575

575576
@retry_on_auth_error
576577
async def execute_scenario(self, oid: str) -> str:
@@ -592,7 +593,7 @@ async def get_setup_options(self) -> list[Option]:
592593
Access scope : Full enduser API access (enduser/*).
593594
"""
594595
response = await self._get("setup/options")
595-
return [Option(**o) for o in decamelize(response)]
596+
return converter.structure(decamelize(response), list[Option])
596597

597598
@retry_on_auth_error
598599
async def get_setup_option(self, option: str) -> Option | None:
@@ -603,7 +604,7 @@ async def get_setup_option(self, option: str) -> Option | None:
603604
response = await self._get(f"setup/options/{option}")
604605

605606
if response:
606-
return Option(**decamelize(response))
607+
return converter.structure(decamelize(response), Option)
607608

608609
return None
609610

@@ -621,7 +622,7 @@ async def get_setup_option_parameter(
621622
response = await self._get(f"setup/options/{option}/{parameter}")
622623

623624
if response:
624-
return OptionParameter(**decamelize(response))
625+
return converter.structure(decamelize(response), OptionParameter)
625626

626627
return None
627628

@@ -653,7 +654,7 @@ async def get_reference_protocol_types(self) -> list[ProtocolType]:
653654
- label: Human-readable protocol label
654655
"""
655656
response = await self._get("reference/protocolTypes")
656-
return [ProtocolType(**protocol) for protocol in response]
657+
return converter.structure(response, list[ProtocolType])
657658

658659
@retry_on_auth_error
659660
async def get_reference_timezones(self) -> JSON:
@@ -683,7 +684,7 @@ async def get_reference_ui_profile(self, profile_name: str) -> UIProfileDefiniti
683684
response = await self._get(
684685
f"reference/ui/profile/{urllib.parse.quote_plus(profile_name)}"
685686
)
686-
return UIProfileDefinition(**decamelize(response))
687+
return converter.structure(decamelize(response), UIProfileDefinition)
687688

688689
@retry_on_auth_error
689690
async def get_reference_ui_profile_names(self) -> list[str]:

pyoverkiz/converter.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Centralized cattrs converter for structuring Overkiz API responses into models."""
2+
3+
from __future__ import annotations
4+
5+
import types
6+
from enum import Enum
7+
from typing import Any, Union, get_args, get_origin
8+
9+
import attr
10+
import cattrs
11+
from cattrs.dispatch import StructureHook
12+
13+
from pyoverkiz.models import (
14+
CommandDefinition,
15+
CommandDefinitions,
16+
State,
17+
States,
18+
)
19+
20+
21+
def _is_union(t: Any) -> bool:
22+
"""Check if a type is a Union or PEP 604 union (X | Y)."""
23+
return get_origin(t) is Union or isinstance(t, types.UnionType)
24+
25+
26+
def _is_primitive_union(t: Any) -> bool:
27+
"""Check if a union type only contains primitive/JSON-native types and enums.
28+
29+
Returns False if any union member is an attrs class (needs structuring).
30+
"""
31+
if not _is_union(t):
32+
return False
33+
for arg in get_args(t):
34+
if arg is type(None):
35+
continue
36+
if isinstance(arg, type) and attr.has(arg):
37+
return False
38+
return True
39+
40+
41+
def _make_converter() -> cattrs.Converter:
42+
c = cattrs.Converter(forbid_extra_keys=False)
43+
44+
# ------------------------------------------------------------------
45+
# Primitive union types (str | Enum, StateType, etc.): passthrough
46+
# These only contain JSON-native types and enums, no attrs classes.
47+
# ------------------------------------------------------------------
48+
c.register_structure_hook_func(_is_primitive_union, lambda v, _: v)
49+
50+
# ------------------------------------------------------------------
51+
# Optional[AttrsClass] unions: structure the non-None member
52+
# ------------------------------------------------------------------
53+
def _is_optional_attrs(t: Any) -> bool:
54+
if not _is_union(t):
55+
return False
56+
args = get_args(t)
57+
non_none = [a for a in args if a is not type(None)]
58+
return (
59+
len(non_none) == 1
60+
and isinstance(non_none[0], type)
61+
and attr.has(non_none[0])
62+
)
63+
64+
def _structure_optional_attrs(v: Any, t: Any) -> Any:
65+
if v is None:
66+
return None
67+
args = get_args(t)
68+
cls = next(a for a in args if a is not type(None))
69+
return c.structure(v, cls)
70+
71+
c.register_structure_hook_func(_is_optional_attrs, _structure_optional_attrs)
72+
73+
# ------------------------------------------------------------------
74+
# Enums: use the enum constructor which handles UnknownEnumMixin
75+
# ------------------------------------------------------------------
76+
c.register_structure_hook_func(
77+
lambda t: isinstance(t, type) and issubclass(t, Enum),
78+
lambda v, t: v if isinstance(v, t) else t(v),
79+
)
80+
81+
# ------------------------------------------------------------------
82+
# attrs classes: silently discard unknown keys from API responses
83+
# ------------------------------------------------------------------
84+
def _make_attrs_hook(cls: type) -> StructureHook:
85+
inner: StructureHook = cattrs.gen.make_dict_structure_fn(cls, c)
86+
87+
def hook(val: Any, _: type) -> Any:
88+
if isinstance(val, dict):
89+
fields = {a.name for a in attr.fields(cls)}
90+
val = {k: v for k, v in val.items() if k in fields}
91+
return inner(val, cls)
92+
93+
return hook
94+
95+
c.register_structure_hook_factory(attr.has, _make_attrs_hook)
96+
97+
# ------------------------------------------------------------------
98+
# Container types with custom __init__
99+
# ------------------------------------------------------------------
100+
def _structure_states(val: Any, _: type) -> States:
101+
if isinstance(val, States):
102+
return val
103+
if val is None:
104+
return States()
105+
return States([c.structure(s, State) for s in val])
106+
107+
def _structure_command_definitions(val: Any, _: type) -> CommandDefinitions:
108+
if isinstance(val, CommandDefinitions):
109+
return val
110+
return CommandDefinitions([c.structure(cd, CommandDefinition) for cd in val])
111+
112+
c.register_structure_hook(States, _structure_states)
113+
c.register_structure_hook(CommandDefinitions, _structure_command_definitions)
114+
115+
return c
116+
117+
118+
converter = _make_converter()

0 commit comments

Comments
 (0)