Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b1499be
Adopt attrs converters, rename ServerConfig.type to api_type, fix Loc…
iMicknl Apr 17, 2026
229f941
Fix States/CommandDefinitions container semantics
iMicknl Apr 17, 2026
4b22346
Type Event.metadata, deleted_raw_devices_count, and protocol_type
iMicknl Apr 17, 2026
67877b8
Add dict index to CommandDefinitions and States for O(1) lookups
iMicknl Apr 17, 2026
5acf843
Simplify Device to use _flexible_init and resolve ui_class/widget at …
iMicknl Apr 17, 2026
784bb08
Simplify DeviceIdentifier to use __attrs_post_init__ for base_device_url
iMicknl Apr 17, 2026
ea74572
Replace _to_sub_places with _to_list("Place") forward reference
iMicknl Apr 17, 2026
3b7f9d5
Replace _to_server_enum and _to_api_type with _to_optional_enum
iMicknl Apr 17, 2026
c42663b
Remove unused _LOGGER and stale pylint comment
iMicknl Apr 17, 2026
04dfb45
Change Gateway.id and Place.id from mutable copies to read-only prope…
iMicknl Apr 17, 2026
fd4e390
Simplify ActionGroup label default (#1992)
iMicknl Apr 17, 2026
ca6ab24
Fix Event.setupoid field name to match decamelize output
iMicknl Apr 17, 2026
89dd381
Fix Execution.action_group type and add missing fields
iMicknl Apr 17, 2026
bc72514
Fix Gateway.connectivity type annotation to be optional
iMicknl Apr 17, 2026
d6a33e3
Remove unused _to_command_definitions function
iMicknl Apr 17, 2026
9b97555
Reorder models to eliminate string forward references
iMicknl Apr 17, 2026
2ae4046
Migrate to cattrs for centralized model structuring
iMicknl Apr 17, 2026
32c74ec
Simplify cattrs converter by removing redundant hooks
iMicknl Apr 18, 2026
9d71d5d
Improve docstrings for _is_primitive_union and _make_converter functi…
iMicknl Apr 18, 2026
6701da1
Fix Optional[Enum] fields bypassing enum structuring in cattrs converter
iMicknl Apr 18, 2026
d9f301b
Add new fields to Event, Gateway, Location, and Setup models for enha…
iMicknl Apr 18, 2026
6f2ae74
Obfuscate country_code field in Location model for enhanced privacy
iMicknl Apr 18, 2026
184f639
Add UpdateCriticityLevel enum and update Gateway model to use it
iMicknl Apr 18, 2026
688a235
Refactor dataclass fields in AuthContext and Credentials for improved…
iMicknl Apr 18, 2026
017b673
Add default values for ui_class and widget in Device model and valida…
iMicknl Apr 19, 2026
569325c
Refactor ActionGroup to use oid as the primary identifier and remove …
iMicknl Apr 19, 2026
cb656fb
Merge v2/main and resolve conflicts
Copilot Apr 21, 2026
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
6 changes: 3 additions & 3 deletions pyoverkiz/auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

import datetime
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Protocol


@dataclass(slots=True)
class AuthContext:
"""Authentication context holding tokens and expiration."""

access_token: str | None = None
refresh_token: str | None = None
access_token: str | None = field(default=None, repr=False)
refresh_token: str | None = field(default=None, repr=False)
expires_at: datetime.datetime | None = None

def is_expired(self, *, skew_seconds: int = 5) -> bool:
Expand Down
8 changes: 4 additions & 4 deletions pyoverkiz/auth/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field


class Credentials:
Expand All @@ -14,14 +14,14 @@ class UsernamePasswordCredentials(Credentials):
"""Credentials using username and password."""

username: str
password: str
password: str = field(repr=False)


@dataclass(slots=True)
class TokenCredentials(Credentials):
"""Credentials using an (API) token."""

token: str
token: str = field(repr=False)


@dataclass(slots=True)
Expand All @@ -33,5 +33,5 @@ class LocalTokenCredentials(TokenCredentials):
class RexelOAuthCodeCredentials(Credentials):
"""Credentials using Rexel OAuth2 authorization code."""

code: str
code: str = field(repr=False)
redirect_uri: str
36 changes: 18 additions & 18 deletions pyoverkiz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.converter import converter
from pyoverkiz.enums import APIType, ExecutionMode, Server
from pyoverkiz.exceptions import (
ExecutionQueueFullError,
Expand Down Expand Up @@ -306,7 +307,7 @@ async def get_setup(self, refresh: bool = False) -> Setup:

response = await self._get("setup")

setup = Setup(**decamelize(response))
setup = converter.structure(decamelize(response), Setup)

# Cache response
self.setup = setup
Expand Down Expand Up @@ -344,7 +345,7 @@ async def get_devices(self, refresh: bool = False) -> list[Device]:
return self.devices

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

# Cache response
self.devices = devices
Expand All @@ -363,7 +364,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
return self.gateways

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

# Cache response
self.gateways = gateways
Expand All @@ -376,7 +377,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
async def get_execution_history(self) -> list[HistoryExecution]:
"""List past executions and their outcomes."""
response = await self._get("history/executions")
return [HistoryExecution(**h) for h in decamelize(response)]
return converter.structure(decamelize(response), list[HistoryExecution])

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

@retry_on_auth_error
async def refresh_states(self) -> None:
Expand Down Expand Up @@ -437,7 +438,7 @@ async def fetch_events(self) -> list[Event]:
"""
await self._refresh_token_if_expired()
response = await self._post(f"events/{self.event_listener_id}/fetch")
return [Event(**e) for e in decamelize(response)]
return converter.structure(decamelize(response), list[Event])

async def unregister_event_listener(self) -> None:
"""Unregister an event listener.
Expand All @@ -455,17 +456,16 @@ async def get_current_execution(self, exec_id: str) -> Execution | None:
Returns None if the execution does not exist.
"""
response = await self._get(f"exec/current/{exec_id}")

if not response or not isinstance(response, dict):
return None

return Execution(**decamelize(response))
return converter.structure(decamelize(response), Execution)

@retry_on_auth_error
async def get_current_executions(self) -> list[Execution]:
"""Get all currently running executions."""
response = await self._get("exec/current")
return [Execution(**e) for e in decamelize(response)]
return converter.structure(decamelize(response), list[Execution])

@retry_on_auth_error
async def get_api_version(self) -> str:
Expand Down Expand Up @@ -557,7 +557,7 @@ async def cancel_execution(self, exec_id: str) -> None:
async def get_action_groups(self) -> list[ActionGroup]:
"""List action groups persisted on the server."""
response = await self._get("actionGroups")
return [ActionGroup(**action_group) for action_group in decamelize(response)]
return converter.structure(decamelize(response), list[ActionGroup])

@retry_on_auth_error
async def get_places(self) -> Place:
Expand All @@ -572,7 +572,7 @@ async def get_places(self) -> Place:
- `sub_places`: List of nested places within this location
"""
response = await self._get("setup/places")
return Place(**decamelize(response))
return converter.structure(decamelize(response), Place)

@retry_on_auth_error
async def execute_persisted_action_group(self, oid: str) -> str:
Expand All @@ -594,7 +594,7 @@ async def get_setup_options(self) -> list[Option]:
Access scope : Full enduser API access (enduser/*).
"""
response = await self._get("setup/options")
return [Option(**o) for o in decamelize(response)]
return converter.structure(decamelize(response), list[Option])

@retry_on_auth_error
async def get_setup_option(self, option: str) -> Option | None:
Expand All @@ -605,7 +605,7 @@ async def get_setup_option(self, option: str) -> Option | None:
response = await self._get(f"setup/options/{option}")

if response:
return Option(**decamelize(response))
return converter.structure(decamelize(response), Option)

return None

Expand All @@ -623,7 +623,7 @@ async def get_setup_option_parameter(
response = await self._get(f"setup/options/{option}/{parameter}")

if response:
return OptionParameter(**decamelize(response))
return converter.structure(decamelize(response), OptionParameter)

return None

Expand Down Expand Up @@ -655,7 +655,7 @@ async def get_reference_protocol_types(self) -> list[ProtocolType]:
- label: Human-readable protocol label
"""
response = await self._get("reference/protocolTypes")
return [ProtocolType(**protocol) for protocol in response]
return converter.structure(response, list[ProtocolType])

@retry_on_auth_error
async def get_reference_timezones(self) -> JSON:
Expand Down Expand Up @@ -685,7 +685,7 @@ async def get_reference_ui_profile(self, profile_name: str) -> UIProfileDefiniti
response = await self._get(
f"reference/ui/profile/{urllib.parse.quote_plus(profile_name)}"
)
return UIProfileDefinition(**decamelize(response))
return converter.structure(decamelize(response), UIProfileDefinition)

@retry_on_auth_error
async def get_reference_ui_profile_names(self) -> list[str]:
Expand All @@ -701,7 +701,7 @@ async def get_reference_ui_widgets(self) -> list[str]:
async def get_devices_not_up_to_date(self) -> list[Device]:
"""Get all devices whose firmware is not up to date."""
response = await self._get("setup/devices/notUpToDate")
return [Device(**d) for d in decamelize(response)]
return converter.structure(decamelize(response), list[Device])

@retry_on_auth_error
async def get_device_firmware_status(self, deviceurl: str) -> FirmwareStatus | None:
Expand All @@ -715,7 +715,7 @@ async def get_device_firmware_status(self, deviceurl: str) -> FirmwareStatus | N
)
except UnsupportedOperationError:
return None
return FirmwareStatus(**decamelize(response))
return converter.structure(decamelize(response), FirmwareStatus)

@retry_on_auth_error
async def get_device_firmware_update_capability(self, deviceurl: str) -> bool:
Expand Down
64 changes: 64 additions & 0 deletions pyoverkiz/converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Centralized cattrs converter for structuring Overkiz API responses."""

from __future__ import annotations

import types
from enum import Enum
from typing import Any, Union, get_args, get_origin

import attr
import cattrs

from pyoverkiz.models import (
CommandDefinition,
CommandDefinitions,
State,
States,
)


def _is_primitive_union(t: Any) -> bool:
"""True for unions of JSON-native types (e.g. StateType).

Excludes unions containing attrs classes (e.g. Definition | None) since those
need actual structuring by cattrs.
"""
origin = get_origin(t)
if origin is not Union and not isinstance(t, types.UnionType):
return False
non_none = [arg for arg in get_args(t) if arg is not type(None)]
if any(isinstance(arg, type) and attr.has(arg) for arg in non_none):
return False
# Exclude pure Optional[Enum] unions — those need the Enum structure hook.
return not all(isinstance(arg, type) and issubclass(arg, Enum) for arg in non_none)


def _make_converter() -> cattrs.Converter:
c = cattrs.Converter()

# JSON-native unions like StateType (str | int | float | … | None) are already the
# correct Python type after JSON parsing — tell cattrs to pass them through as-is.
c.register_structure_hook_func(_is_primitive_union, lambda v, _: v)

# Enums: call the constructor so UnknownEnumMixin._missing_ can handle unknown values
c.register_structure_hook_func(
lambda t: isinstance(t, type) and issubclass(t, Enum),
lambda v, t: v if isinstance(v, t) else t(v),
)
Comment on lines +20 to +47
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

The “primitive union passthrough” structure hook currently matches Optional[Enum] / Enum | None fields (e.g., FailureType | None, GatewayType | None) and returns the raw value unchanged, preventing the Enum hook from running. This regresses previous behavior where these fields were converted to their Enum types. Consider excluding Enum subclasses from _is_primitive_union, and/or adding a dedicated hook for Optional[Enum] so unions containing enums are still structured into enums.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +47
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

_is_primitive_union() currently treats Optional[Enum] / Enum | None (and other unions that include Enum subclasses) as a “primitive union” and returns the raw value unchanged. That bypasses the Enum structure hook, so fields like Event.failure_type_code: FailureType | None and Event.old_state/new_state: ExecutionState | None may remain int/str instead of Enum instances, breaking code that relies on Enum attributes (e.g., .name) and skipping UnknownEnumMixin handling. Consider narrowing this hook to only JSON-native primitives (str/int/float/bool/None/dict/list) and explicitly excluding Enum subclasses (and possibly other non-primitive types).

Copilot uses AI. Check for mistakes.

# Custom container types that take a list in __init__
def _structure_states(val: Any, _: type) -> States:
if val is None:
return States()
return States([c.structure(s, State) for s in val])

def _structure_command_definitions(val: Any, _: type) -> CommandDefinitions:
return CommandDefinitions([c.structure(cd, CommandDefinition) for cd in val])

c.register_structure_hook(States, _structure_states)
c.register_structure_hook(CommandDefinitions, _structure_command_definitions)

return c


converter = _make_converter()
8 changes: 7 additions & 1 deletion pyoverkiz/enums/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
ExecutionSubType,
ExecutionType,
)
from pyoverkiz.enums.gateway import GatewaySubType, GatewayType, UpdateBoxStatus
from pyoverkiz.enums.gateway import (
GatewaySubType,
GatewayType,
UpdateBoxStatus,
UpdateCriticityLevel,
)
from pyoverkiz.enums.general import DataType, EventName, FailureType, ProductType
from pyoverkiz.enums.measured_value_type import MeasuredValueType
from pyoverkiz.enums.protocol import Protocol
Expand Down Expand Up @@ -40,4 +45,5 @@
"UIProfile",
"UIWidget",
"UpdateBoxStatus",
"UpdateCriticityLevel",
]
9 changes: 9 additions & 0 deletions pyoverkiz/enums/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ def beautify_name(self) -> str:
)


@unique
class UpdateCriticityLevel(UnknownEnumMixin, StrEnum):
"""Criticity level of an available gateway update."""

BUG_FIX = "BUG_FIX"
DEVICES_CONTROL_ONLY = "DEVICES_CONTROL_ONLY"
UNKNOWN = "UNKNOWN"


@unique
class UpdateBoxStatus(StrEnum):
"""Status of the gateway update box indicating its updateability."""
Expand Down
Loading