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
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ Two entry points live in `divera247.websocket`:

```python
from divera247.websocket import (
ClusterPullEvent,
UnknownEvent,
UserStatusEvent,
stream_websocket,
Expand All @@ -164,9 +163,7 @@ async for event in stream_websocket(client, ucr_id=527_459):
match event:
case UserStatusEvent(payload=payload, ucr=ucr):
...
case ClusterPullEvent(pull=pull, cluster=cluster):
...
case UnknownEvent(type=msg_type):
case UnknownEvent():
... # forward-compatible fallback
```

Expand Down
17 changes: 6 additions & 11 deletions src/divera247/websocket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@
JWT re-auth.
* :class:`WebSocketAuthenticationError` - raised when authentication keeps
failing so the caller can react instead of silently looping.
* Pydantic event envelopes (:class:`ClusterPullEvent`,
:class:`UserStatusEvent`, :class:`UnknownEvent`) and the
:data:`DiveraEvent` discriminated union plus :func:`parse_event` for
dispatching raw frames onto typed models.
* Pydantic event envelopes (:class:`UserStatusEvent`,
:class:`UnknownEvent`) and the :data:`DiveraEvent` discriminated union
plus :func:`parse_event` for dispatching raw frames onto typed models.
Other server-side event types (e.g. ``cluster-pull``,
``cluster-vehicle``) currently surface as :class:`UnknownEvent` until a
real sample is available to back a dedicated model.

Typical usage:

.. code-block:: python

from divera247.websocket import (
ClusterPullEvent,
UnknownEvent,
UserStatusEvent,
subscribe_websocket,
Expand All @@ -31,15 +32,11 @@
match event:
case UserStatusEvent(payload=payload, ucr=ucr):
...
case ClusterPullEvent(pull=pull):
...
case UnknownEvent(type=msg_type):
...
"""

from divera247.websocket.models import (
ClusterPullEvent,
ClusterPullRef,
DiveraEvent,
UnknownEvent,
UserStatusEvent,
Expand All @@ -52,8 +49,6 @@
)

__all__ = [
'ClusterPullEvent',
'ClusterPullRef',
'DiveraEvent',
'UnknownEvent',
'UserStatusEvent',
Expand Down
76 changes: 44 additions & 32 deletions src/divera247/websocket/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
"""Pydantic models for Divera 24/7 WebSocket push events."""

import logging
from collections.abc import Mapping
from typing import Annotated, Any, Literal

from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag, TypeAdapter
from pydantic import (
BaseModel,
ConfigDict,
Discriminator,
Field,
Tag,
TypeAdapter,
ValidationError,
)

from divera247.models.pull import PullStatusData

logger = logging.getLogger(__name__)


class UserStatusEvent(BaseModel):
"""``user-status`` WebSocket event: own status changed for a given UCR.
Expand All @@ -22,41 +33,23 @@ class UserStatusEvent(BaseModel):
ucr: int = Field(description='ID der betroffenen UserClusterRelation')


class ClusterPullRef(BaseModel):
"""Reference to the specific cluster sub-resource that changed."""

type: str = Field(description='Name des betroffenen Pull-Blocks')
id: int = Field(description='ID des geänderten Eintrags')


class ClusterPullEvent(BaseModel):
"""``cluster-pull`` WebSocket event: a cluster resource was updated.

Clients use this as a hint to re-fetch the affected block via
``GET /api/v2/pull/all`` (or the matching scoped endpoint) to obtain the
new state. The event itself only carries the reference, not the payload.
"""

type: Literal['cluster-pull'] = Field(description='Event-Typ')
pull: ClusterPullRef = Field(description='Referenz auf das geänderte Element')
cluster: int = Field(description='ID der betroffenen Einheit')


class UnknownEvent(BaseModel):
"""Fallback for any WebSocket event type we don't have a dedicated model for.

Keeps the raw ``type`` string so callers can still dispatch on it, and
preserves every other top-level field as extras (accessible via
:attr:`model_extra` or direct attribute access). Use this to log
previously unseen event types so dedicated models can be added later.
:attr:`model_extra` or direct attribute access). Used both for genuinely
unknown event types (e.g. ``cluster-pull``, ``cluster-vehicle``) and as
a defensive fallback when a known event's inner payload fails its
dedicated validation (see :func:`parse_event`).
"""

model_config = ConfigDict(extra='allow')

type: str = Field(description='Raw event type as sent by the server')


_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull'})
_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status'})


def _event_discriminator(value: Any) -> str:
Expand All @@ -73,9 +66,7 @@ def _event_discriminator(value: Any) -> str:


DiveraEvent = Annotated[
Annotated[UserStatusEvent, Tag('user-status')]
| Annotated[ClusterPullEvent, Tag('cluster-pull')]
| Annotated[UnknownEvent, Tag('unknown')],
Annotated[UserStatusEvent, Tag('user-status')] | Annotated[UnknownEvent, Tag('unknown')],
Discriminator(_event_discriminator),
]
"""Discriminated union of every typed WebSocket event plus the catch-all.
Expand All @@ -85,16 +76,37 @@ def _event_discriminator(value: Any) -> str:
"""


_event_adapter: TypeAdapter[UserStatusEvent | ClusterPullEvent | UnknownEvent] = TypeAdapter(
DiveraEvent,
)
_event_adapter: TypeAdapter[UserStatusEvent | UnknownEvent] = TypeAdapter(DiveraEvent)


def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | UnknownEvent:
def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent:
"""Parse a raw WebSocket event into the matching typed model.

Dispatches on ``type`` via :data:`DiveraEvent`; unknown or missing types
fall back to :class:`UnknownEvent` instead of raising, so the subscribe
loop never dies on a newly introduced server-side event name.

If a frame carries a known ``type`` but its nested payload fails the
dedicated validation (e.g. the server changed the wire format in a way
we don't yet model), this also falls back to :class:`UnknownEvent` and
logs the raw frame at WARNING level -- the subscribe loop keeps
yielding events instead of dying on a single unexpected shape, and
the log line gives you everything needed to update the typed model.

Frames that are missing a ``type`` field entirely (or whose ``type`` is
not a string) are still rejected, since they are malformed and cannot
be routed to any model, not even :class:`UnknownEvent`.
"""
return _event_adapter.validate_python(event)
try:
return _event_adapter.validate_python(event)
except ValidationError:
event_type = event.get('type') if isinstance(event, Mapping) else None
if not isinstance(event_type, str) or not event_type:
raise
logger.warning(
'failed to validate %r WebSocket event against its typed model; '
'falling back to UnknownEvent. raw frame: %r',
event_type,
dict(event),
)
return UnknownEvent.model_validate(event)
4 changes: 2 additions & 2 deletions src/divera247/websocket/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async def subscribe_websocket(
ucr_id: int | None = None,
ws_url: str = 'wss://ws.divera247.com/ws',
max_auth_attempts: int = 3,
) -> AsyncIterator[models.ClusterPullEvent | models.UserStatusEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.UnknownEvent]:
"""Yield typed Divera 24/7 WebSocket events from a single session.

Exits when the underlying socket disconnects (by raising
Expand All @@ -169,7 +169,7 @@ async def stream_websocket( # noqa: PLR0913
max_backoff: float = 60.0,
backoff_factor: float = 2.0,
backoff_jitter: float = 0.2,
) -> AsyncIterator[models.ClusterPullEvent | models.UserStatusEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.UnknownEvent]:
"""Yield events forever, transparently reconnecting on any disconnect.

Reconnect delay follows jittered exponential backoff bounded by
Expand Down
70 changes: 30 additions & 40 deletions tests/websocket/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import pytest

from divera247.websocket.models import (
ClusterPullEvent,
ClusterPullRef,
UnknownEvent,
UserStatusEvent,
parse_event,
Expand Down Expand Up @@ -40,12 +38,6 @@
'ucr': SAMPLE_UCR,
}

_CLUSTER_PULL_SAMPLE: dict = {
'type': 'cluster-pull',
'pull': {'type': 'news', 'id': SAMPLE_PULL_ID},
'cluster': SAMPLE_CLUSTER,
}


def test_user_status_event_parses_sample() -> None:
"""UserStatusEvent accepts the documented envelope + pull status payload."""
Expand All @@ -62,29 +54,6 @@ def test_user_status_event_rejects_wrong_type_literal() -> None:
UserStatusEvent.model_validate(bad)


def test_cluster_pull_ref_parses_minimal_fields() -> None:
"""ClusterPullRef exposes ``type`` + ``id`` from the nested ``pull`` block."""
ref = ClusterPullRef.model_validate({'type': 'news', 'id': SAMPLE_REF_ID})
assert ref.type == 'news'
assert ref.id == SAMPLE_REF_ID


def test_cluster_pull_event_parses_sample() -> None:
"""ClusterPullEvent exposes both the cluster id and the typed reference."""
event = ClusterPullEvent.model_validate(_CLUSTER_PULL_SAMPLE)
assert event.type == 'cluster-pull'
assert event.cluster == SAMPLE_CLUSTER
assert event.pull.type == 'news'
assert event.pull.id == SAMPLE_PULL_ID


def test_cluster_pull_event_rejects_wrong_type_literal() -> None:
"""Non-matching ``type`` value must not validate as ClusterPullEvent."""
bad = dict(_CLUSTER_PULL_SAMPLE, type='user-status')
with pytest.raises(ValueError, match='cluster-pull'):
ClusterPullEvent.model_validate(bad)


def test_unknown_event_keeps_arbitrary_type() -> None:
"""UnknownEvent stores the original ``type`` string instead of overwriting it."""
event = UnknownEvent.model_validate({'type': 'cluster-vehicle'})
Expand All @@ -111,16 +80,17 @@ def test_parse_event_dispatches_user_status() -> None:
assert parsed.ucr == SAMPLE_UCR


def test_parse_event_dispatches_cluster_pull() -> None:
"""``parse_event`` routes ``cluster-pull`` frames to ClusterPullEvent."""
parsed = parse_event(_CLUSTER_PULL_SAMPLE)
assert isinstance(parsed, ClusterPullEvent)
assert parsed.cluster == SAMPLE_CLUSTER


@pytest.mark.parametrize('event_type', ['cluster-message', 'cluster-vehicle', 'some-brand-new-event'])
@pytest.mark.parametrize(
'event_type',
['cluster-pull', 'cluster-message', 'cluster-vehicle', 'some-brand-new-event'],
)
def test_parse_event_falls_back_to_unknown_and_preserves_type(event_type: str) -> None:
"""Unknown ``type`` values route to UnknownEvent with the original string + extras intact."""
"""Unknown ``type`` values route to UnknownEvent with the original string + extras intact.

``cluster-pull`` is included here explicitly: it is a known server event
but has no typed envelope yet (awaiting a real live-API sample), so it
must currently flow through the unknown-event fallback.
"""
raw = {'type': event_type, 'foo': 1, 'bar': [1, 2]}
parsed = parse_event(raw)
assert isinstance(parsed, UnknownEvent)
Expand All @@ -138,3 +108,23 @@ def test_parse_event_non_string_type_still_raises() -> None:
"""A numeric ``type`` is not a valid tag and must not be coerced into UnknownEvent."""
with pytest.raises(ValueError, match='type'):
parse_event({'type': 123})


def test_parse_event_falls_back_when_known_type_has_malformed_payload(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Known ``type`` with an unexpectedly shaped body degrades to UnknownEvent with a warning.

Guards against the subscribe loop dying on a single malformed frame when
the server starts sending a slightly different shape; the raw frame is
logged so the typed model can be updated.
"""
malformed = {'type': 'user-status', 'ucr': SAMPLE_UCR, 'payload': 'not-a-status-object'}

with caplog.at_level('WARNING', logger='divera247.websocket.models'):
parsed = parse_event(malformed)

assert isinstance(parsed, UnknownEvent)
assert parsed.type == 'user-status'
assert parsed.model_extra == {'ucr': SAMPLE_UCR, 'payload': 'not-a-status-object'}
assert any('user-status' in record.message for record in caplog.records)
Loading