Skip to content
Merged
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
86 changes: 85 additions & 1 deletion docs/migration-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,90 @@ and `device.definition.commands`.
- `Gateway.connectivity` is now `Connectivity | None` (was always set in v1).
- `Gateway.id` and `Place.id` are now read-only properties.

## Events

In v1, `Event` was a single flat class that carried every field any event could
possibly have, so every field was optional and present on every event regardless
of its `name`. In v2, `Event` is the base of a **typed hierarchy**: structuring an
event payload returns a concrete subtype chosen by its `name` (e.g.
`DeviceStateChangedEvent`, `ExecutionStateChangedEvent`, `GatewayDownEvent`). The
base `Event` keeps only the fields common to every event (`name`, `timestamp`,
`setup_oid`, `owning_partners`); everything else lives on the relevant subtype.

The model is three rules:

1. **Each modeled event is its own typed class** whose fields are that event's
payload — narrow with `isinstance` and the present fields are typed. Related
events share a **category base** carrying that category's identity field, so
you can narrow broadly or precisely:

| Category base | Identity field | Leaf subtypes |
|---------------|----------------|---------------|
| `DeviceEvent` | `device_url` | `DeviceStateChangedEvent`, `DeviceAvailableEvent`, … |
| `GatewayEvent` | `gateway_id` | `GatewayDownEvent`, `GatewayAliveEvent`, … |
| `ZoneEvent` | `zone_oid` | `ZoneCreatedEvent`, `ZoneUpdatedEvent`, `ZoneDeletedEvent` |
| `ExecutionEvent` | `exec_id` | `ExecutionRegisteredEvent`, `ExecutionStateChangedEvent` |

2. **All `*FailedEvent` names structure into one `FailureEvent`.** Consumers
branch on *did it fail, and why* (`failure_type`), not on which of the ~30
operations failed; the specific operation is in `event.name`. (See the
`FailureEvent` docstring.)
3. **Anything unmodeled is the base `Event`** — including new names the API adds
later — so unknown events never raise. Check `event.name`.

Narrow with `isinstance` before accessing subtype-specific fields:

=== "v1"

```python
for event in events:
# Every field existed on every Event (None when not applicable)
if event.device_states:
...
```

=== "v2"

```python
from pyoverkiz.models import DeviceStateChangedEvent

for event in events:
# device_states only exists on DeviceStateChangedEvent
if isinstance(event, DeviceStateChangedEvent):
for state in event.device_states:
...
```

### Strict subtypes, resilient batches

Subtypes mark the fields the API is documented to always send for that event as
**required** (no `None` default): `device_url` on device events, `gateway_id` on
gateway events, `zone_oid` on zone events, and `exec_id` / `new_state` /
`old_state` on execution-state events. This means once you have narrowed to a
subtype, those fields are guaranteed non-`None` — no defensive checks needed.

To keep that strictness from making event fetching fragile, structuring degrades
**per event**: if a single payload is missing a required field (an undocumented
API quirk or partial data), that one event falls back to the base `Event` and a
warning is logged — the rest of the batch is unaffected. A flood of such warnings
is a signal that a field marked required should be loosened.

### Removed fields

These v1 `Event` fields are not carried by any v2 event subtype and are no longer
available:

| Field | v1 events that carried it |
|-------|---------------------------|
| `camera_id` | `CameraDiscoveredEvent`, `CameraUploadPhotoEvent` |
| `condition_groupoid` | `ConditionGroup*Event` |
| `deleted_raw_devices_count` | `PurgePartialRawDevicesEvent` |

`failure_type_code` is no longer on failure events either: the API only ever
sends it on `ExecutionStateChangedEvent`, where it remains. The `*FailedEvent`
payloads carry `failure_type` (plus `gateway_id` / `device_url` / `protocol_type`
where applicable), all surfaced on `FailureEvent`.

## Authentication methods

The per-server login helpers on `OverkizClient` have been removed. Authentication is now handled internally by the credential/strategy system — call the single `login()` method, which dispatches to the correct strategy based on the `Credentials` you passed to the constructor.
Expand Down Expand Up @@ -363,7 +447,7 @@ These changes affect you if you subclass `OverkizClient` or use internal APIs:
| `deviceurl` | `device_url` |
| `Event.setupoid` | `Event.setup_oid` |

Update any keyword arguments and attribute accesses using the old spelling. `Event` also gains `actions`, `owner`, and `source` fields.
Update any keyword arguments and attribute accesses using the old spelling. The `actions`, `owner`, and `source` fields now live on `ExecutionRegisteredEvent` (see [Events](#events)).

## Model defaults

Expand Down
3 changes: 3 additions & 0 deletions pyoverkiz/_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ def recursive_key_map(data: Any, key_fn: Callable[[str], str]) -> Any:

_CAMELIZE_OVERRIDES: dict[str, str] = {
"device_url": "deviceURL",
"device_urls": "deviceURLs",
"place_oid": "placeOID",
"place_oids": "placeOIDs",
"setup_oid": "setupOID",
"zone_oid": "zoneOID",
}


Expand Down
54 changes: 53 additions & 1 deletion pyoverkiz/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@

from __future__ import annotations

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

import attr
import cattrs
from cattrs.errors import ClassValidationError
from cattrs.gen import make_dict_structure_fn, override

from pyoverkiz._case import camelize_key
from pyoverkiz.enums import GatewaySubType
from pyoverkiz.enums import EventName, GatewaySubType
from pyoverkiz.models import (
EVENT_TYPE_BY_NAME,
CommandDefinition,
CommandDefinitions,
Event,
State,
StateDefinition,
StateDefinitions,
States,
)

_LOGGER = logging.getLogger(__name__)


def _is_primitive_union(t: Any) -> bool:
"""True for unions of JSON-native types (e.g. StateType).
Expand Down Expand Up @@ -103,6 +109,52 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions:
_rename_hook_factory,
)

# Event is a discriminated union keyed on "name". Pre-build each subtype's
# hook and call it directly; routing via c.structure(val, subtype) would
# re-enter this hook (subclass dispatches to its base) and recurse forever.
event_types: set[type[Event]] = {Event, *EVENT_TYPE_BY_NAME.values()}
event_hooks: dict[type[Event], Any] = {
cls: _rename_hook_factory(cls, c) for cls in event_types
}

def _structure_event(val: Any, _: type) -> Event:
name = val.get("name") if isinstance(val, dict) else None
target: type[Event] = Event
if name is not None:
target = EVENT_TYPE_BY_NAME.get(EventName(name), Event)
try:
return event_hooks[target](val, target) # type: ignore[no-any-return]
except ClassValidationError as err:
# A payload missing a required field degrades to base Event rather
# than failing the whole batch; the warning flags a field to loosen.
if target is Event:
raise
_LOGGER.warning(
"Could not structure %s as %s (%s); falling back to base Event",
name,
target.__name__,
err,
)
return event_hooks[Event](val, Event) # type: ignore[no-any-return]

c.register_structure_hook(Event, _structure_event)

def _structure_event_list(val: Any, _: type) -> list[Event]:
# A single unstructurable event (e.g. missing "name", or not a dict) must
# not sink the whole poll. _structure_event already degrades known
# subtypes to base Event; here we drop the few items it can't build at all.
if not val:
return []
events: list[Event] = []
for raw in val:
try:
events.append(_structure_event(raw, Event))
except (ClassValidationError, ValueError, TypeError) as err:
_LOGGER.warning("Dropping unstructurable event %r (%s)", raw, err)
return events

c.register_structure_hook_func(lambda t: t == list[Event], _structure_event_list)

return c


Expand Down
Loading