From 6edaaef94e1737f668a839af537613cbe32d3ef0 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Fri, 9 Jan 2026 15:16:32 +0100 Subject: [PATCH] refactor(models): use cattrs to reduce boilerplate in model definitions --- pyoverkiz/converters.py | 43 +++++++++++++++++++++ pyoverkiz/models.py | 86 ++++++++--------------------------------- pyproject.toml | 1 + uv.lock | 22 +++++++++-- 4 files changed, 79 insertions(+), 73 deletions(-) create mode 100644 pyoverkiz/converters.py diff --git a/pyoverkiz/converters.py b/pyoverkiz/converters.py new file mode 100644 index 00000000..10c3943a --- /dev/null +++ b/pyoverkiz/converters.py @@ -0,0 +1,43 @@ +"""Converters for structuring API data into model instances using cattrs.""" + +from __future__ import annotations + +from enum import Enum, IntEnum +from typing import TypeVar + +import cattrs + +from pyoverkiz.enums import StrEnum + +E = TypeVar("E", bound=Enum) + +# Global converter instance configured to ignore extra keys from API responses +converter = cattrs.Converter(forbid_extra_keys=False) + + +def _structure_enum(val: int | str | None, enum_cls: type[E]) -> E | None: + """Structure an enum value, returning None if the input is None.""" + if val is None: + return None + return enum_cls(val) + + +def configure_converter() -> None: + """Register structure hooks for all pyoverkiz enums. + + This must be called after importing the enums module to ensure all enum + classes are available for registration. + """ + from pyoverkiz import enums + + # Exclude base enum classes that are re-exported from the enums module + base_classes = (Enum, IntEnum, StrEnum) + + for name in dir(enums): + obj = getattr(enums, name) + if isinstance(obj, type) and issubclass(obj, Enum) and obj not in base_classes: + converter.register_structure_hook(obj, _structure_enum) + + +# Configure the converter when this module is imported +configure_converter() diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 0c591572..b0356561 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -8,6 +8,7 @@ from attr import define, field +from pyoverkiz.converters import converter from pyoverkiz.enums import ( DataType, EventName, @@ -68,7 +69,7 @@ def __init__( self.id = id self.creation_time = creation_time self.last_update_time = last_update_time - self.location = Location(**location) if location else None + self.location = converter.structure(location, Location) if location else None self.gateways = [Gateway(**g) for g in gateways] self.devices = [Device(**d) for d in devices] self.zones = [Zone(**z) for z in zones] if zones else None @@ -78,71 +79,28 @@ def __init__( self.features = [Feature(**f) for f in features] if features else None -@define(init=False, kw_only=True) +@define(kw_only=True) class Location: """Geographical and address metadata for a Setup.""" creation_time: str - last_update_time: str | None = None - city: str = field(repr=obfuscate_string, default=None) - country: str = field(repr=obfuscate_string, default=None) - postal_code: str = field(repr=obfuscate_string, default=None) - address_line1: str = field(repr=obfuscate_string, default=None) - address_line2: str = field(repr=obfuscate_string, default=None) timezone: str - longitude: str = field(repr=obfuscate_string, default=None) - latitude: str = field(repr=obfuscate_string, default=None) twilight_mode: int twilight_angle: str - twilight_city: str | None = None summer_solstice_dusk_minutes: str winter_solstice_dusk_minutes: str twilight_offset_enabled: bool dawn_offset: int dusk_offset: int - - def __init__( - self, - *, - creation_time: str, - last_update_time: str | None = None, - city: str = field(repr=obfuscate_string, default=None), - country: str = field(repr=obfuscate_string, default=None), - postal_code: str = field(repr=obfuscate_string, default=None), - address_line1: str = field(repr=obfuscate_string, default=None), - address_line2: str = field(repr=obfuscate_string, default=None), - timezone: str, - longitude: str = field(repr=obfuscate_string, default=None), - latitude: str = field(repr=obfuscate_string, default=None), - twilight_mode: int, - twilight_angle: str, - twilight_city: str | None = None, - summer_solstice_dusk_minutes: str, - winter_solstice_dusk_minutes: str, - twilight_offset_enabled: bool, - dawn_offset: int, - dusk_offset: int, - **_: Any, - ) -> None: - """Initialize Location with address and timezone information.""" - self.creation_time = creation_time - self.last_update_time = last_update_time - self.city = city - self.country = country - self.postal_code = postal_code - self.address_line1 = address_line1 - self.address_line2 = address_line2 - self.timezone = timezone - self.longitude = longitude - self.latitude = latitude - self.twilight_mode = twilight_mode - self.twilight_angle = twilight_angle - self.twilight_city = twilight_city - self.summer_solstice_dusk_minutes = summer_solstice_dusk_minutes - self.winter_solstice_dusk_minutes = winter_solstice_dusk_minutes - self.twilight_offset_enabled = twilight_offset_enabled - self.dawn_offset = dawn_offset - self.dusk_offset = dusk_offset + last_update_time: str | None = None + city: str | None = field(repr=obfuscate_string, default=None) + country: str | None = field(repr=obfuscate_string, default=None) + postal_code: str | None = field(repr=obfuscate_string, default=None) + address_line1: str | None = field(repr=obfuscate_string, default=None) + address_line2: str | None = field(repr=obfuscate_string, default=None) + longitude: str | None = field(repr=obfuscate_string, default=None) + latitude: str | None = field(repr=obfuscate_string, default=None) + twilight_city: str | None = None @define(init=False, kw_only=True) @@ -659,7 +617,7 @@ def __init__( self.oid = oid -@define(init=False, kw_only=True) +@define(kw_only=True) class Partner: """Partner details for a gateway or service provider.""" @@ -668,26 +626,14 @@ class Partner: id: str = field(repr=obfuscate_id) status: str - def __init__(self, activated: bool, name: str, id: str, status: str, **_: Any): - """Initialize Partner information.""" - self.activated = activated - self.name = name - self.id = id - self.status = status - -@define(init=False, kw_only=True) +@define(kw_only=True) class Connectivity: """Connectivity metadata for a gateway update box.""" status: str protocol_version: str - def __init__(self, status: str, protocol_version: str, **_: Any): - """Initialize Connectivity information.""" - self.status = status - self.protocol_version = protocol_version - @define(init=False, kw_only=True) class Gateway: @@ -734,11 +680,11 @@ def __init__( self.mode = mode self.place_oid = place_oid self.time_reliable = time_reliable - self.connectivity = Connectivity(**connectivity) + self.connectivity = converter.structure(connectivity, Connectivity) self.up_to_date = up_to_date self.update_status = UpdateBoxStatus(update_status) if update_status else None self.sync_in_progress = sync_in_progress - self.partners = [Partner(**p) for p in partners] if partners else [] + self.partners = converter.structure(partners, list[Partner]) if partners else [] self.type = GatewayType(type) if type else None self.sub_type = GatewaySubType(sub_type) if sub_type else None diff --git a/pyproject.toml b/pyproject.toml index 56d9d073..e09f9732 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pyhumps<4.0.0,>=3.8.0", "backoff<3.0,>=1.10.0", "attrs>=21.2", + "cattrs>=24.1.0", "boto3<2.0.0,>=1.18.59", "warrant-lite<2.0.0,>=1.0.4", "backports-strenum<2.0.0,>=1.2.4; python_version < \"3.11\"", diff --git a/uv.lock b/uv.lock index 05f8dc40..d219d600 100644 --- a/uv.lock +++ b/uv.lock @@ -226,6 +226,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, ] +[[package]] +name = "cattrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, +] + [[package]] name = "certifi" version = "2025.6.15" @@ -1025,6 +1039,7 @@ dependencies = [ { name = "backoff" }, { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "boto3" }, + { name = "cattrs" }, { name = "pyhumps" }, { name = "warrant-lite" }, ] @@ -1048,6 +1063,7 @@ requires-dist = [ { name = "backoff", specifier = ">=1.10.0,<3.0" }, { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.2.4,<2.0.0" }, { name = "boto3", specifier = ">=1.18.59,<2.0.0" }, + { name = "cattrs", specifier = ">=24.1.0" }, { name = "pyhumps", specifier = ">=3.8.0,<4.0.0" }, { name = "warrant-lite", specifier = ">=1.0.4,<2.0.0" }, ] @@ -1318,7 +1334,7 @@ wheels = [ [[package]] name = "tox" -version = "4.33.0" +version = "4.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -1333,9 +1349,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/d7/ccf2f7fb162170cd5bb4ac7c682dadf1159bae3c5c6d22dae0b2d5936336/tox-4.33.0.tar.gz", hash = "sha256:a29244bce3f514f94043e173366aa191c8cf0106ec8ddd18ba53f985acd73cc4", size = 204690, upload-time = "2026-01-02T22:52:53.904Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/bb/d32b9e1624326f3333c4d568d9eb16784548369f32c30d89bb639cb3a337/tox-4.34.0.tar.gz", hash = "sha256:18f27e064aec1af6fec47f391e39d60974af0a1dc2c6da5030d9c710f4ee95b7", size = 205371, upload-time = "2026-01-08T16:11:20.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/cd/dd273f8896ce51014f106d133b79bdca6c650b9281271b247db2f693061c/tox-4.33.0-py3-none-any.whl", hash = "sha256:8582ac5c3ca97095ce88ae6bcd310d22614350ea9751b0e4ad39acad7874e270", size = 176556, upload-time = "2026-01-02T22:52:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/78/ca/cc4121bbe5217e14e35d95941cc756bfedd25fbfe276acd34a131fbaa08f/tox-4.34.0-py3-none-any.whl", hash = "sha256:dccfb69c60fd32d5d0fee862bb4596b7ccee013b4b6fc4b8ea7a6a1fcf165384", size = 176941, upload-time = "2026-01-08T16:11:18.727Z" }, ] [[package]]