Coverage for object_streams/events.py: 85%
117 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 17:07 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 17:07 +0000
1"""Serializable event primitives used by subscriptions and the outbox."""
3from __future__ import annotations
5from collections.abc import Mapping
6from collections.abc import Sequence
7from dataclasses import dataclass
8from dataclasses import field
9from enum import StrEnum
10from typing import Any
12from django.db import models
15__all__ = (
16 "EventOperation",
17 "ListAction",
18 "ObjectRef",
19 "SourceRef",
20 "StreamEvent",
21)
24class EventOperation(StrEnum):
25 CREATED = "created"
26 UPDATED = "updated"
27 DELETED = "deleted"
30class ListAction(StrEnum):
31 ADDED = "added"
32 CHANGED = "changed"
33 REMOVED = "removed"
34 DELETED = "deleted"
35 RESYNC_REQUIRED = "resync_required"
38def _model_label(model: type[models.Model] | str) -> str:
39 if isinstance(model, str):
40 return model
41 return model._meta.label
44def _coerce_pk(pk: Any) -> str:
45 if pk is None: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 msg = "Object stream references require a primary key."
47 raise ValueError(msg)
48 return str(pk)
51@dataclass(frozen=True, slots=True)
52class ObjectRef:
53 """A serializable reference to a Django model object."""
55 model: str
56 pk: str
58 def __post_init__(self):
59 object.__setattr__(self, "model", _model_label(self.model))
60 object.__setattr__(self, "pk", _coerce_pk(self.pk))
62 @classmethod
63 def for_model(cls, model: type[models.Model] | str, pk: Any) -> ObjectRef:
64 return cls(model=_model_label(model), pk=_coerce_pk(pk))
66 @classmethod
67 def from_instance(cls, instance: models.Model) -> ObjectRef:
68 return cls.for_model(instance.__class__, instance.pk)
70 def as_dict(self) -> dict[str, str]:
71 return {
72 "model": self.model,
73 "pk": self.pk,
74 }
77@dataclass(frozen=True, slots=True)
78class SourceRef:
79 """A serializable reference to the source that produced a subject event."""
81 model: str | None = None
82 pk: str | None = None
83 history_model: str | None = None
84 history_id: str | None = None
85 metadata: Mapping[str, Any] = field(default_factory=dict)
87 def __post_init__(self):
88 if self.model is not None: 88 ↛ 90line 88 didn't jump to line 90 because the condition on line 88 was always true
89 object.__setattr__(self, "model", _model_label(self.model))
90 if self.pk is not None: 90 ↛ 92line 90 didn't jump to line 92 because the condition on line 90 was always true
91 object.__setattr__(self, "pk", str(self.pk))
92 if self.history_model is not None:
93 object.__setattr__(self, "history_model", _model_label(self.history_model))
94 if self.history_id is not None:
95 object.__setattr__(self, "history_id", str(self.history_id))
96 object.__setattr__(self, "metadata", dict(self.metadata))
98 @classmethod
99 def from_instance(cls, instance: models.Model, *, metadata: Mapping[str, Any] | None = None) -> SourceRef:
100 return cls(model=instance.__class__, pk=instance.pk, metadata=metadata or {})
102 def as_dict(self) -> dict[str, Any]:
103 value: dict[str, Any] = {}
104 if self.model: 104 ↛ 106line 104 didn't jump to line 106 because the condition on line 104 was always true
105 value["model"] = self.model
106 if self.pk: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was always true
107 value["pk"] = self.pk
108 if self.history_model: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 value["history_model"] = self.history_model
110 if self.history_id: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 value["history_id"] = self.history_id
112 if self.metadata: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 value["metadata"] = dict(self.metadata)
114 return value
117@dataclass(frozen=True, slots=True)
118class StreamEvent:
119 """A subscription-relative event suitable for WebSocket or outbox delivery."""
121 subject: ObjectRef
122 facet: str = "object"
123 op: EventOperation | str = EventOperation.UPDATED
124 cursor: int | None = None
125 subscription_id: str | None = None
126 list_action: ListAction | str | None = None
127 changed_fields: Sequence[str] = field(default_factory=tuple)
128 source: ObjectRef | SourceRef | None = None
129 fetch: bool = True
130 before: Mapping[str, Any] | None = None
131 after: Mapping[str, Any] | None = None
132 metadata: Mapping[str, Any] = field(default_factory=dict)
134 def __post_init__(self):
135 object.__setattr__(self, "op", str(self.op))
136 if self.list_action is not None:
137 object.__setattr__(self, "list_action", str(self.list_action))
138 object.__setattr__(self, "changed_fields", tuple(self.changed_fields))
139 object.__setattr__(self, "metadata", dict(self.metadata))
140 if self.before is not None: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 object.__setattr__(self, "before", dict(self.before))
142 if self.after is not None:
143 object.__setattr__(self, "after", dict(self.after))
145 def as_dict(self) -> dict[str, Any]:
146 value: dict[str, Any] = {
147 "type": "event",
148 "subject": self.subject.as_dict(),
149 "facet": self.facet,
150 "op": self.op,
151 "changed_fields": list(self.changed_fields),
152 "fetch": self.fetch,
153 }
154 if self.subscription_id is not None: 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true
155 value["subscription_id"] = self.subscription_id
156 if self.cursor is not None: 156 ↛ 158line 156 didn't jump to line 158 because the condition on line 156 was always true
157 value["cursor"] = self.cursor
158 if self.list_action is not None: 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was always true
159 value["list_action"] = self.list_action
160 if self.source is not None:
161 value["source"] = self.source.as_dict()
162 if self.before is not None: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 value["before"] = dict(self.before)
164 if self.after is not None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 value["after"] = dict(self.after)
166 if self.metadata: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 value["metadata"] = dict(self.metadata)
168 return value