From 1f7b9b07c7173626d8421cb680926e400a62bd44 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 21 Apr 2026 21:57:32 +0200 Subject: [PATCH 1/4] remove typing for cluster pull --- README.md | 5 +- src/divera247/websocket/__init__.py | 17 +++---- src/divera247/websocket/models.py | 76 +++++++++++++++++------------ src/divera247/websocket/session.py | 4 +- tests/websocket/test_models.py | 70 ++++++++++++-------------- tests/websocket/test_session.py | 33 +++++-------- tests/websocket/test_stream.py | 31 ++++++++---- 7 files changed, 117 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 0351687..3506b08 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,6 @@ Two entry points live in `divera247.websocket`: ```python from divera247.websocket import ( - ClusterPullEvent, UnknownEvent, UserStatusEvent, stream_websocket, @@ -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 ``` diff --git a/src/divera247/websocket/__init__.py b/src/divera247/websocket/__init__.py index 9cdc26b..9f705ce 100644 --- a/src/divera247/websocket/__init__.py +++ b/src/divera247/websocket/__init__.py @@ -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, @@ -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, @@ -52,8 +49,6 @@ ) __all__ = [ - 'ClusterPullEvent', - 'ClusterPullRef', 'DiveraEvent', 'UnknownEvent', 'UserStatusEvent', diff --git a/src/divera247/websocket/models.py b/src/divera247/websocket/models.py index b614968..254c05a 100644 --- a/src/divera247/websocket/models.py +++ b/src/divera247/websocket/models.py @@ -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. @@ -22,33 +33,15 @@ 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') @@ -56,7 +49,7 @@ class UnknownEvent(BaseModel): 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: @@ -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. @@ -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) diff --git a/src/divera247/websocket/session.py b/src/divera247/websocket/session.py index 1a86120..169de87 100644 --- a/src/divera247/websocket/session.py +++ b/src/divera247/websocket/session.py @@ -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 @@ -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 diff --git a/tests/websocket/test_models.py b/tests/websocket/test_models.py index e1cea05..577fcd0 100644 --- a/tests/websocket/test_models.py +++ b/tests/websocket/test_models.py @@ -5,8 +5,6 @@ import pytest from divera247.websocket.models import ( - ClusterPullEvent, - ClusterPullRef, UnknownEvent, UserStatusEvent, parse_event, @@ -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.""" @@ -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'}) @@ -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) @@ -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) diff --git a/tests/websocket/test_session.py b/tests/websocket/test_session.py index 16a5f63..9a48e3e 100644 --- a/tests/websocket/test_session.py +++ b/tests/websocket/test_session.py @@ -21,7 +21,6 @@ from divera247.client import Divera247Client from divera247.websocket import session as session_module from divera247.websocket.models import ( - ClusterPullEvent, UnknownEvent, UserStatusEvent, ) @@ -37,7 +36,6 @@ EXPECTED_UCR = 527459 -EXPECTED_CLUSTER = 8381 EXPECTED_AUTH_ATTEMPT_BUDGET = 3 EXPECTED_REAUTH_FRAMES = 2 @@ -59,11 +57,6 @@ }, 'ucr': EXPECTED_UCR, } -_CLUSTER_PULL_FRAME = { - 'type': 'cluster-pull', - 'pull': {'type': 'news', 'id': 2029889}, - 'cluster': EXPECTED_CLUSTER, -} class FakeWebSocketSession: @@ -221,17 +214,14 @@ async def test_subscribe_websocket_yields_known_event_types( [ {'type': 'init'}, _USER_STATUS_FRAME, - _CLUSTER_PULL_FRAME, ] ) _install_fake_ws(monkeypatch, fake) - events = await _collect(subscribe_websocket(ws_client), limit=2) + events = await _collect(subscribe_websocket(ws_client), limit=1) assert isinstance(events[0], UserStatusEvent) assert events[0].ucr == EXPECTED_UCR - assert isinstance(events[1], ClusterPullEvent) - assert events[1].cluster == EXPECTED_CLUSTER async def test_subscribe_websocket_unknown_event_falls_back_to_unknown_model( @@ -239,10 +229,11 @@ async def test_subscribe_websocket_unknown_event_falls_back_to_unknown_model( ws_client: Divera247Client, ) -> None: """Unknown ``type`` frames surface as UnknownEvent with the original data intact.""" + raw_extras = {'payload': {'id': 42}, 'cluster': 8381} fake = FakeWebSocketSession( [ {'type': 'init'}, - {'type': 'some-brand-new-event', 'payload': {'id': 42}, 'cluster': EXPECTED_CLUSTER}, + {'type': 'some-brand-new-event', **raw_extras}, ] ) _install_fake_ws(monkeypatch, fake) @@ -251,7 +242,7 @@ async def test_subscribe_websocket_unknown_event_falls_back_to_unknown_model( assert isinstance(events[0], UnknownEvent) assert events[0].type == 'some-brand-new-event' - assert events[0].model_extra == {'payload': {'id': 42}, 'cluster': EXPECTED_CLUSTER} + assert events[0].model_extra == raw_extras async def test_subscribe_websocket_init_frame_is_not_yielded( @@ -259,13 +250,13 @@ async def test_subscribe_websocket_init_frame_is_not_yielded( ws_client: Divera247Client, ) -> None: """``init`` is session-level and must stay internal to the session loop.""" - fake = FakeWebSocketSession([{'type': 'init'}, _CLUSTER_PULL_FRAME]) + fake = FakeWebSocketSession([{'type': 'init'}, _USER_STATUS_FRAME]) _install_fake_ws(monkeypatch, fake) events = await _collect(subscribe_websocket(ws_client), limit=1) assert len(events) == 1 - assert isinstance(events[0], ClusterPullEvent) + assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_ignores_non_json_frames( @@ -277,14 +268,14 @@ async def test_subscribe_websocket_ignores_non_json_frames( [ {'type': 'init'}, 'this is not json', - _CLUSTER_PULL_FRAME, + _USER_STATUS_FRAME, ] ) _install_fake_ws(monkeypatch, fake) events = await _collect(subscribe_websocket(ws_client), limit=1) - assert isinstance(events[0], ClusterPullEvent) + assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_reauthenticates_on_jwt_expired( @@ -297,14 +288,14 @@ async def test_subscribe_websocket_reauthenticates_on_jwt_expired( {'type': 'init'}, {'type': 'jwtExpired'}, {'type': 'init'}, - _CLUSTER_PULL_FRAME, + _USER_STATUS_FRAME, ] ) _install_fake_ws(monkeypatch, fake) events = await _collect(subscribe_websocket(ws_client), limit=1) - assert isinstance(events[0], ClusterPullEvent) + assert isinstance(events[0], UserStatusEvent) auth_frames = [frame for frame in fake.sent if frame.get('type') == 'authenticate'] assert len(auth_frames) == EXPECTED_REAUTH_FRAMES @@ -338,7 +329,7 @@ async def test_subscribe_websocket_init_resets_auth_attempt_budget( {'type': 'jwtExpired'}, {'type': 'jwtExpired'}, {'type': 'init'}, - _CLUSTER_PULL_FRAME, + _USER_STATUS_FRAME, ] fake = FakeWebSocketSession(frames) _install_fake_ws(monkeypatch, fake) @@ -348,7 +339,7 @@ async def test_subscribe_websocket_init_resets_auth_attempt_budget( limit=1, ) - assert isinstance(events[0], ClusterPullEvent) + assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_propagates_disconnect( diff --git a/tests/websocket/test_stream.py b/tests/websocket/test_stream.py index f9165db..0b3e650 100644 --- a/tests/websocket/test_stream.py +++ b/tests/websocket/test_stream.py @@ -17,7 +17,7 @@ from divera247.auth import AccessKeyAuth from divera247.client import Divera247Client from divera247.websocket import session as session_module -from divera247.websocket.models import ClusterPullEvent +from divera247.websocket.models import UserStatusEvent from divera247.websocket.session import ( WebSocketAuthenticationError, stream_websocket, @@ -26,11 +26,24 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator, Iterable, Sequence -EXPECTED_CLUSTER = 8381 -_CLUSTER_PULL_FRAME: dict[str, Any] = { - 'type': 'cluster-pull', - 'pull': {'type': 'news', 'id': 2029889}, - 'cluster': EXPECTED_CLUSTER, +EXPECTED_UCR = 527459 +_USER_STATUS_FRAME: dict[str, Any] = { + 'type': 'user-status', + 'payload': { + 'status_id': 33035, + 'status_skip_statusplan': False, + 'status_skip_geofence': False, + 'status_set_date': 1776767153, + 'status_reset_date': '', + 'status_reset_id': 0, + 'status_log': [], + 'status_changes': [], + 'note': '', + 'vehicle': 0, + 'ts': 1776767153, + 'cached': False, + }, + 'ucr': EXPECTED_UCR, } @@ -105,15 +118,15 @@ async def test_stream_websocket_reconnects_after_disconnect( opened = _install_stream_ws( monkeypatch, [ - _FakeSession([{'type': 'init'}, _CLUSTER_PULL_FRAME]), - _FakeSession([{'type': 'init'}, _CLUSTER_PULL_FRAME]), + _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), + _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), ], ) events = await _collect(stream_websocket(ws_client, initial_backoff=0.0), limit=2) assert len(events) == 2 - assert all(isinstance(event, ClusterPullEvent) for event in events) + assert all(isinstance(event, UserStatusEvent) for event in events) assert len(opened) == 2 From 2da60e15e88608f7f31477d54b8ef9dff25d88f2 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 22 Apr 2026 17:14:12 +0200 Subject: [PATCH 2/4] add cluster pull --- README.md | 7 +- src/divera247/websocket/__init__.py | 16 +++- src/divera247/websocket/models.py | 103 +++++++++++++++++++--- src/divera247/websocket/session.py | 4 +- tests/websocket/test_models.py | 127 +++++++++++++++++++++------- tests/websocket/test_session.py | 29 ++++--- tests/websocket/test_stream.py | 29 ++++--- 7 files changed, 237 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 3506b08..15b50f9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Two entry points live in `divera247.websocket`: ```python from divera247.websocket import ( + ClusterPullEvent, UnknownEvent, UserStatusEvent, stream_websocket, @@ -161,8 +162,10 @@ from divera247.websocket import ( async for event in stream_websocket(client, ucr_id=527_459): match event: - case UserStatusEvent(payload=payload, ucr=ucr): - ... + case UserStatusEvent(ucr=ucr, status=status): + ... # status is a PullStatusData + case ClusterPullEvent(cluster=cluster, pull=pull): + ... # re-fetch pull.type (e.g. "alarm") + pull.id case UnknownEvent(): ... # forward-compatible fallback ``` diff --git a/src/divera247/websocket/__init__.py b/src/divera247/websocket/__init__.py index 9f705ce..e5c1cba 100644 --- a/src/divera247/websocket/__init__.py +++ b/src/divera247/websocket/__init__.py @@ -12,17 +12,19 @@ * :class:`WebSocketAuthenticationError` - raised when authentication keeps failing so the caller can react instead of silently looping. * Pydantic event envelopes (:class:`UserStatusEvent`, + :class:`ClusterPullEvent` with its :class:`ClusterPullRef`, :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. + Other server-side event types (e.g. ``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, @@ -30,13 +32,17 @@ async for event in subscribe_websocket(client, ucr_id=ucr_id): match event: - case UserStatusEvent(payload=payload, ucr=ucr): + case UserStatusEvent(ucr=ucr, status=status): ... + case ClusterPullEvent(cluster=cluster, pull=pull): + ... # re-fetch pull.type / pull.id for this cluster case UnknownEvent(type=msg_type): ... """ from divera247.websocket.models import ( + ClusterPullEvent, + ClusterPullRef, DiveraEvent, UnknownEvent, UserStatusEvent, @@ -49,6 +55,8 @@ ) __all__ = [ + 'ClusterPullEvent', + 'ClusterPullRef', 'DiveraEvent', 'UnknownEvent', 'UserStatusEvent', diff --git a/src/divera247/websocket/models.py b/src/divera247/websocket/models.py index 254c05a..a3b1c26 100644 --- a/src/divera247/websocket/models.py +++ b/src/divera247/websocket/models.py @@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal from pydantic import ( + AliasPath, BaseModel, ConfigDict, Discriminator, @@ -19,18 +20,92 @@ logger = logging.getLogger(__name__) +class ClusterPullRef(BaseModel): + """Reference to the cluster resource that triggered a ``cluster-pull`` event. + + The ``type`` names the resource family (observed: ``alarm``; likely + also ``message``, ``event``, ``news``, ``vehicle``, etc., i.e. the + same families exposed by ``GET /api/v2/pull/all``) and ``id`` is the + primary key within that family. Callers should re-fetch the matching + pull endpoint to get the full object. + """ + + model_config = ConfigDict(extra='allow') + + type: str = Field(description='Ressource-Typ, der neu gepullt werden soll (z. B. ``alarm``)') + id: int = Field(description='ID der betroffenen Ressource innerhalb ihres Typs') + + +class ClusterPullEvent(BaseModel): + """``cluster-pull`` WebSocket event: a cluster-scoped resource changed. + + The server tells us *that* something in a given cluster changed and + *which* resource to refresh (type + id). The actual object is not + inlined; the client is expected to re-fetch the relevant pull + endpoint to materialise the update. + + Wire format, mirroring :class:`UserStatusEvent`: + + .. code-block:: json + + { + "type": "cluster-pull", + "payload": { + "type": "cluster-pull", + "pull": {"type": "alarm", "id": 123456}, + "cluster": 1234 + } + } + + Flattened via :class:`~pydantic.AliasPath` so callers access + ``event.pull`` / ``event.cluster`` directly. + """ + + type: Literal['cluster-pull'] = Field(description='Event-Typ') + pull: ClusterPullRef = Field( + validation_alias=AliasPath('payload', 'pull'), + description='Referenz auf die geänderte Ressource (aus payload.pull)', + ) + cluster: int = Field( + validation_alias=AliasPath('payload', 'cluster'), + description='ID des betroffenen Clusters (aus payload.cluster)', + ) + + class UserStatusEvent(BaseModel): """``user-status`` WebSocket event: own status changed for a given UCR. - The ``payload`` has the exact same shape as the ``status`` block of - ``GET /api/v2/pull/all`` (see :class:`PullStatusData`); the envelope - adds the event ``type`` discriminator and the ``ucr`` of the affected - UserClusterRelation. + The server wire-format wraps the actual status (same fields as the + ``status`` block of ``GET /api/v2/pull/all`` -- see + :class:`PullStatusData`) and the affected ``ucr`` inside a nested + ``payload`` object, with the event type repeated redundantly at both + levels: + + .. code-block:: json + + { + "type": "user-status", + "payload": { + "type": "user-status", + "status": { ...PullStatusData... }, + "ucr": 527459 + } + } + + We flatten this on validation via :class:`~pydantic.AliasPath`, so + callers only deal with ``event.status`` / ``event.ucr`` and never have + to reach through a redundant payload wrapper. """ type: Literal['user-status'] = Field(description='Event-Typ') - payload: PullStatusData = Field(description='Aktueller Status der UCR') - ucr: int = Field(description='ID der betroffenen UserClusterRelation') + status: PullStatusData = Field( + validation_alias=AliasPath('payload', 'status'), + description='Aktueller Status der UCR (aus payload.status)', + ) + ucr: int = Field( + validation_alias=AliasPath('payload', 'ucr'), + description='ID der betroffenen UserClusterRelation (aus payload.ucr)', + ) class UnknownEvent(BaseModel): @@ -39,9 +114,9 @@ class UnknownEvent(BaseModel): 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). 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`). + unknown event types (e.g. ``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') @@ -49,7 +124,7 @@ class UnknownEvent(BaseModel): type: str = Field(description='Raw event type as sent by the server') -_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status'}) +_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull'}) def _event_discriminator(value: Any) -> str: @@ -66,7 +141,9 @@ def _event_discriminator(value: Any) -> str: DiveraEvent = Annotated[ - Annotated[UserStatusEvent, Tag('user-status')] | Annotated[UnknownEvent, Tag('unknown')], + Annotated[UserStatusEvent, Tag('user-status')] + | Annotated[ClusterPullEvent, Tag('cluster-pull')] + | Annotated[UnknownEvent, Tag('unknown')], Discriminator(_event_discriminator), ] """Discriminated union of every typed WebSocket event plus the catch-all. @@ -76,10 +153,10 @@ def _event_discriminator(value: Any) -> str: """ -_event_adapter: TypeAdapter[UserStatusEvent | UnknownEvent] = TypeAdapter(DiveraEvent) +_event_adapter: TypeAdapter[UserStatusEvent | ClusterPullEvent | UnknownEvent] = TypeAdapter(DiveraEvent) -def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent: +def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | UnknownEvent: """Parse a raw WebSocket event into the matching typed model. Dispatches on ``type`` via :data:`DiveraEvent`; unknown or missing types diff --git a/src/divera247/websocket/session.py b/src/divera247/websocket/session.py index 169de87..ffbffcb 100644 --- a/src/divera247/websocket/session.py +++ b/src/divera247/websocket/session.py @@ -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.UserStatusEvent | models.UnknownEvent]: +) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.UnknownEvent]: """Yield typed Divera 24/7 WebSocket events from a single session. Exits when the underlying socket disconnects (by raising @@ -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.UserStatusEvent | models.UnknownEvent]: +) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.UnknownEvent]: """Yield events forever, transparently reconnecting on any disconnect. Reconnect delay follows jittered exponential backoff bounded by diff --git a/tests/websocket/test_models.py b/tests/websocket/test_models.py index 577fcd0..ae20ce5 100644 --- a/tests/websocket/test_models.py +++ b/tests/websocket/test_models.py @@ -5,6 +5,8 @@ import pytest from divera247.websocket.models import ( + ClusterPullEvent, + ClusterPullRef, UnknownEvent, UserStatusEvent, parse_event, @@ -13,47 +15,105 @@ SAMPLE_UCR = 527459 SAMPLE_STATUS_ID = 33035 SAMPLE_CLUSTER = 8381 -SAMPLE_PULL_ID = 2029889 +SAMPLE_PULL_ID = 33688274 +SAMPLE_PULL_TYPE = 'alarm' SAMPLE_REF_ID = 42 +_STATUS_BLOCK: dict = { + 'status_id': SAMPLE_STATUS_ID, + 'status_skip_statusplan': False, + 'status_skip_geofence': False, + 'status_set_date': 1776767153, + 'status_reset_date': '', + 'status_reset_id': 0, + 'status_log': [], + 'status_changes': [ + {'ts': 1776767114, 'status': 33035, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0}, + {'ts': 1776767152, 'status': 33036, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0}, + ], + 'note': '', + 'vehicle': 0, + 'ts': 1776767153, + 'cached': False, +} + _USER_STATUS_SAMPLE: dict = { 'type': 'user-status', 'payload': { - 'status_id': SAMPLE_STATUS_ID, - 'status_skip_statusplan': False, - 'status_skip_geofence': False, - 'status_set_date': 1776767153, - 'status_reset_date': '', - 'status_reset_id': 0, - 'status_log': [], - 'status_changes': [ - {'ts': 1776767114, 'status': 33035, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0}, - {'ts': 1776767152, 'status': 33036, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0}, - ], - 'note': '', - 'vehicle': 0, - 'ts': 1776767153, - 'cached': False, + 'type': 'user-status', + 'status': _STATUS_BLOCK, + 'ucr': SAMPLE_UCR, + }, +} + +_CLUSTER_PULL_SAMPLE: dict = { + 'type': 'cluster-pull', + 'payload': { + 'type': 'cluster-pull', + 'pull': {'type': SAMPLE_PULL_TYPE, 'id': SAMPLE_PULL_ID}, + 'cluster': SAMPLE_CLUSTER, }, - 'ucr': SAMPLE_UCR, } -def test_user_status_event_parses_sample() -> None: - """UserStatusEvent accepts the documented envelope + pull status payload.""" +def test_user_status_event_flattens_nested_payload() -> None: + """UserStatusEvent hoists ``status`` and ``ucr`` out of the nested payload via AliasPath.""" event = UserStatusEvent.model_validate(_USER_STATUS_SAMPLE) assert event.type == 'user-status' assert event.ucr == SAMPLE_UCR - assert event.payload.status_id == SAMPLE_STATUS_ID + assert event.status.status_id == SAMPLE_STATUS_ID -def test_user_status_event_rejects_wrong_type_literal() -> None: - """Non-matching ``type`` value must not validate as UserStatusEvent.""" +def test_user_status_event_rejects_wrong_outer_type_literal() -> None: + """Non-matching outer ``type`` value must not validate as UserStatusEvent.""" bad = dict(_USER_STATUS_SAMPLE, type='something-else') with pytest.raises(ValueError, match='user-status'): UserStatusEvent.model_validate(bad) +def test_user_status_event_requires_nested_payload_keys() -> None: + """Missing ``payload.status`` or ``payload.ucr`` breaks validation. + + Guards the :class:`~pydantic.AliasPath` flattening: if the server ever + drops the nested structure, we want a hard failure (and thus a + fallback to :class:`UnknownEvent` via :func:`parse_event`) instead of + silently producing an event with dummy fields. + """ + bad = dict(_USER_STATUS_SAMPLE, payload={'type': 'user-status'}) + with pytest.raises(ValueError, match=r'status|ucr'): + UserStatusEvent.model_validate(bad) + + +def test_cluster_pull_event_flattens_nested_payload() -> None: + """ClusterPullEvent hoists ``pull`` and ``cluster`` out of the nested payload via AliasPath.""" + event = ClusterPullEvent.model_validate(_CLUSTER_PULL_SAMPLE) + assert event.type == 'cluster-pull' + assert event.cluster == SAMPLE_CLUSTER + assert isinstance(event.pull, ClusterPullRef) + assert event.pull.type == SAMPLE_PULL_TYPE + assert event.pull.id == SAMPLE_PULL_ID + + +def test_cluster_pull_event_rejects_wrong_outer_type_literal() -> None: + """Non-matching outer ``type`` value must not validate as ClusterPullEvent.""" + bad = dict(_CLUSTER_PULL_SAMPLE, type='something-else') + with pytest.raises(ValueError, match='cluster-pull'): + ClusterPullEvent.model_validate(bad) + + +def test_cluster_pull_ref_preserves_unknown_fields() -> None: + """Extra fields on the pull reference round-trip via ``model_extra``. + + The server is free to enrich the ``pull`` reference (e.g. with a + ``title`` or ``updated_at``) without breaking this model. + """ + raw = {'type': SAMPLE_PULL_TYPE, 'id': SAMPLE_PULL_ID, 'title': 'Einsatz', 'prio': 1} + ref = ClusterPullRef.model_validate(raw) + assert ref.type == SAMPLE_PULL_TYPE + assert ref.id == SAMPLE_PULL_ID + assert ref.model_extra == {'title': 'Einsatz', 'prio': 1} + + 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'}) @@ -78,19 +138,24 @@ def test_parse_event_dispatches_user_status() -> None: parsed = parse_event(_USER_STATUS_SAMPLE) assert isinstance(parsed, UserStatusEvent) assert parsed.ucr == SAMPLE_UCR + assert parsed.status.status_id == SAMPLE_STATUS_ID + + +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 + assert parsed.pull.type == SAMPLE_PULL_TYPE + assert parsed.pull.id == SAMPLE_PULL_ID @pytest.mark.parametrize( 'event_type', - ['cluster-pull', 'cluster-message', 'cluster-vehicle', 'some-brand-new-event'], + ['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. - - ``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. - """ + """Unknown ``type`` values route to UnknownEvent with the original string + extras intact.""" raw = {'type': event_type, 'foo': 1, 'bar': [1, 2]} parsed = parse_event(raw) assert isinstance(parsed, UnknownEvent) @@ -119,12 +184,12 @@ def test_parse_event_falls_back_when_known_type_has_malformed_payload( 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'} + malformed = {'type': 'user-status', 'payload': 'not-a-payload-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 parsed.model_extra == {'payload': 'not-a-payload-object'} assert any('user-status' in record.message for record in caplog.records) diff --git a/tests/websocket/test_session.py b/tests/websocket/test_session.py index 9a48e3e..ec51df3 100644 --- a/tests/websocket/test_session.py +++ b/tests/websocket/test_session.py @@ -42,20 +42,23 @@ _USER_STATUS_FRAME = { 'type': 'user-status', 'payload': { - 'status_id': 33035, - 'status_skip_statusplan': False, - 'status_skip_geofence': False, - 'status_set_date': 1776767153, - 'status_reset_date': '', - 'status_reset_id': 0, - 'status_log': [], - 'status_changes': [], - 'note': '', - 'vehicle': 0, - 'ts': 1776767153, - 'cached': False, + 'type': 'user-status', + 'status': { + 'status_id': 33035, + 'status_skip_statusplan': False, + 'status_skip_geofence': False, + 'status_set_date': 1776767153, + 'status_reset_date': '', + 'status_reset_id': 0, + 'status_log': [], + 'status_changes': [], + 'note': '', + 'vehicle': 0, + 'ts': 1776767153, + 'cached': False, + }, + 'ucr': EXPECTED_UCR, }, - 'ucr': EXPECTED_UCR, } diff --git a/tests/websocket/test_stream.py b/tests/websocket/test_stream.py index 0b3e650..3b7a813 100644 --- a/tests/websocket/test_stream.py +++ b/tests/websocket/test_stream.py @@ -30,20 +30,23 @@ _USER_STATUS_FRAME: dict[str, Any] = { 'type': 'user-status', 'payload': { - 'status_id': 33035, - 'status_skip_statusplan': False, - 'status_skip_geofence': False, - 'status_set_date': 1776767153, - 'status_reset_date': '', - 'status_reset_id': 0, - 'status_log': [], - 'status_changes': [], - 'note': '', - 'vehicle': 0, - 'ts': 1776767153, - 'cached': False, + 'type': 'user-status', + 'status': { + 'status_id': 33035, + 'status_skip_statusplan': False, + 'status_skip_geofence': False, + 'status_set_date': 1776767153, + 'status_reset_date': '', + 'status_reset_id': 0, + 'status_log': [], + 'status_changes': [], + 'note': '', + 'vehicle': 0, + 'ts': 1776767153, + 'cached': False, + }, + 'ucr': EXPECTED_UCR, }, - 'ucr': EXPECTED_UCR, } From e7d14c98c3a7ed66db0042a667f7fdf6cb8a8e20 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 22 Apr 2026 17:23:58 +0200 Subject: [PATCH 3/4] fixes --- README.md | 1 + src/divera247/websocket/models.py | 32 +++++-------------------------- tests/websocket/test_models.py | 9 +++------ tests/websocket/test_session.py | 12 ------------ tests/websocket/test_stream.py | 3 --- 5 files changed, 9 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index f1fe18d..15b50f9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Two entry points live in `divera247.websocket`: ```python from divera247.websocket import ( + ClusterPullEvent, UnknownEvent, UserStatusEvent, stream_websocket, diff --git a/src/divera247/websocket/models.py b/src/divera247/websocket/models.py index 8c6d043..a3b1c26 100644 --- a/src/divera247/websocket/models.py +++ b/src/divera247/websocket/models.py @@ -124,7 +124,7 @@ class UnknownEvent(BaseModel): type: str = Field(description='Raw event type as sent by the server') -_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status'}) +_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull'}) def _event_discriminator(value: Any) -> str: @@ -141,7 +141,9 @@ def _event_discriminator(value: Any) -> str: DiveraEvent = Annotated[ - Annotated[UserStatusEvent, Tag('user-status')] | Annotated[UnknownEvent, Tag('unknown')], + Annotated[UserStatusEvent, Tag('user-status')] + | Annotated[ClusterPullEvent, Tag('cluster-pull')] + | Annotated[UnknownEvent, Tag('unknown')], Discriminator(_event_discriminator), ] """Discriminated union of every typed WebSocket event plus the catch-all. @@ -154,7 +156,7 @@ def _event_discriminator(value: Any) -> str: _event_adapter: TypeAdapter[UserStatusEvent | ClusterPullEvent | UnknownEvent] = TypeAdapter(DiveraEvent) -def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent: +def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | UnknownEvent: """Parse a raw WebSocket event into the matching typed model. Dispatches on ``type`` via :data:`DiveraEvent`; unknown or missing types @@ -168,17 +170,6 @@ def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent: 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`. - - 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`. @@ -196,16 +187,3 @@ def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent: dict(event), ) return UnknownEvent.model_validate(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) diff --git a/tests/websocket/test_models.py b/tests/websocket/test_models.py index b8de5e2..ae20ce5 100644 --- a/tests/websocket/test_models.py +++ b/tests/websocket/test_models.py @@ -5,6 +5,8 @@ import pytest from divera247.websocket.models import ( + ClusterPullEvent, + ClusterPullRef, UnknownEvent, UserStatusEvent, parse_event, @@ -153,12 +155,7 @@ def test_parse_event_dispatches_cluster_pull() -> None: ['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. - - ``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. - """ + """Unknown ``type`` values route to UnknownEvent with the original string + extras intact.""" raw = {'type': event_type, 'foo': 1, 'bar': [1, 2]} parsed = parse_event(raw) assert isinstance(parsed, UnknownEvent) diff --git a/tests/websocket/test_session.py b/tests/websocket/test_session.py index cf24d79..ec51df3 100644 --- a/tests/websocket/test_session.py +++ b/tests/websocket/test_session.py @@ -221,7 +221,6 @@ async def test_subscribe_websocket_yields_known_event_types( ) _install_fake_ws(monkeypatch, fake) - events = await _collect(subscribe_websocket(ws_client), limit=1) events = await _collect(subscribe_websocket(ws_client), limit=1) assert isinstance(events[0], UserStatusEvent) @@ -234,12 +233,10 @@ async def test_subscribe_websocket_unknown_event_falls_back_to_unknown_model( ) -> None: """Unknown ``type`` frames surface as UnknownEvent with the original data intact.""" raw_extras = {'payload': {'id': 42}, 'cluster': 8381} - raw_extras = {'payload': {'id': 42}, 'cluster': 8381} fake = FakeWebSocketSession( [ {'type': 'init'}, {'type': 'some-brand-new-event', **raw_extras}, - {'type': 'some-brand-new-event', **raw_extras}, ] ) _install_fake_ws(monkeypatch, fake) @@ -249,7 +246,6 @@ async def test_subscribe_websocket_unknown_event_falls_back_to_unknown_model( assert isinstance(events[0], UnknownEvent) assert events[0].type == 'some-brand-new-event' assert events[0].model_extra == raw_extras - assert events[0].model_extra == raw_extras async def test_subscribe_websocket_init_frame_is_not_yielded( @@ -258,14 +254,12 @@ async def test_subscribe_websocket_init_frame_is_not_yielded( ) -> None: """``init`` is session-level and must stay internal to the session loop.""" fake = FakeWebSocketSession([{'type': 'init'}, _USER_STATUS_FRAME]) - fake = FakeWebSocketSession([{'type': 'init'}, _USER_STATUS_FRAME]) _install_fake_ws(monkeypatch, fake) events = await _collect(subscribe_websocket(ws_client), limit=1) assert len(events) == 1 assert isinstance(events[0], UserStatusEvent) - assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_ignores_non_json_frames( @@ -278,7 +272,6 @@ async def test_subscribe_websocket_ignores_non_json_frames( {'type': 'init'}, 'this is not json', _USER_STATUS_FRAME, - _USER_STATUS_FRAME, ] ) _install_fake_ws(monkeypatch, fake) @@ -286,7 +279,6 @@ async def test_subscribe_websocket_ignores_non_json_frames( events = await _collect(subscribe_websocket(ws_client), limit=1) assert isinstance(events[0], UserStatusEvent) - assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_reauthenticates_on_jwt_expired( @@ -300,14 +292,12 @@ async def test_subscribe_websocket_reauthenticates_on_jwt_expired( {'type': 'jwtExpired'}, {'type': 'init'}, _USER_STATUS_FRAME, - _USER_STATUS_FRAME, ] ) _install_fake_ws(monkeypatch, fake) events = await _collect(subscribe_websocket(ws_client), limit=1) - assert isinstance(events[0], UserStatusEvent) assert isinstance(events[0], UserStatusEvent) auth_frames = [frame for frame in fake.sent if frame.get('type') == 'authenticate'] assert len(auth_frames) == EXPECTED_REAUTH_FRAMES @@ -343,7 +333,6 @@ async def test_subscribe_websocket_init_resets_auth_attempt_budget( {'type': 'jwtExpired'}, {'type': 'init'}, _USER_STATUS_FRAME, - _USER_STATUS_FRAME, ] fake = FakeWebSocketSession(frames) _install_fake_ws(monkeypatch, fake) @@ -354,7 +343,6 @@ async def test_subscribe_websocket_init_resets_auth_attempt_budget( ) assert isinstance(events[0], UserStatusEvent) - assert isinstance(events[0], UserStatusEvent) async def test_subscribe_websocket_propagates_disconnect( diff --git a/tests/websocket/test_stream.py b/tests/websocket/test_stream.py index 5c84569..3b7a813 100644 --- a/tests/websocket/test_stream.py +++ b/tests/websocket/test_stream.py @@ -123,8 +123,6 @@ async def test_stream_websocket_reconnects_after_disconnect( [ _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), - _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), - _FakeSession([{'type': 'init'}, _USER_STATUS_FRAME]), ], ) @@ -132,7 +130,6 @@ async def test_stream_websocket_reconnects_after_disconnect( assert len(events) == 2 assert all(isinstance(event, UserStatusEvent) for event in events) - assert all(isinstance(event, UserStatusEvent) for event in events) assert len(opened) == 2 From 59338e4639cf0cbf40b3789f6c309a85fdf774dd Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 22 Apr 2026 17:30:05 +0200 Subject: [PATCH 4/4] fix export --- src/divera247/websocket/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/divera247/websocket/__init__.py b/src/divera247/websocket/__init__.py index a499bbe..e5c1cba 100644 --- a/src/divera247/websocket/__init__.py +++ b/src/divera247/websocket/__init__.py @@ -24,6 +24,7 @@ .. code-block:: python from divera247.websocket import ( + ClusterPullEvent, UnknownEvent, UserStatusEvent, subscribe_websocket, @@ -40,6 +41,8 @@ """ from divera247.websocket.models import ( + ClusterPullEvent, + ClusterPullRef, DiveraEvent, UnknownEvent, UserStatusEvent, @@ -52,6 +55,8 @@ ) __all__ = [ + 'ClusterPullEvent', + 'ClusterPullRef', 'DiveraEvent', 'UnknownEvent', 'UserStatusEvent',