Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions pyoverkiz/converters.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +25 to +39
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enum structure hooks registered in configure_converter are not currently used by the refactored models (Location, Partner, Connectivity don't have enum fields). While this is fine as preparation for future refactoring, the current implementation may not correctly handle optional enum fields (e.g., UpdateBoxStatus | None). Consider adding tests for enum structuring before migrating models with enum fields, or add a hook for Optional[Enum] types using cattrs.register_structure_hook for Union types.

Copilot uses AI. Check for mistakes.


# Configure the converter when this module is imported
configure_converter()
86 changes: 16 additions & 70 deletions pyoverkiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from attr import define, field

from pyoverkiz.converters import converter
from pyoverkiz.enums import (
DataType,
EventName,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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."""

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
22 changes: 19 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading