python-cqrs 5.2.0__py3-none-any.whl → 5.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cqrs/__init__.py CHANGED
@@ -46,6 +46,14 @@ Outbox on ``cqrs``: ``OutboxedEventRepository``, ``OutboxedEventMap``,
46
46
  the ``sqlmodel`` extra. Codecs on ``cqrs``: ``JsonEventSerializer``,
47
47
  ``ProtobufEventSerializer`` (extra ``protobuf``).
48
48
 
49
+ Inbox on ``cqrs``: ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
50
+ ``InboxMessageMap``, ``InboxProcessor``. ``SqlAlchemyInboxRepository`` and
51
+ ``rebind_inbox_model`` are lazy and need the ``sqlalchemy`` extra.
52
+ ``SqlModelInboxRepository`` is lazy and needs the ``sqlmodel`` extra.
53
+ Presentation calls ``add`` with a batch. ``InboxProcessor`` reads rows and
54
+ calls the mediator that is already registered. FastAPI and FastStream are
55
+ not imported here.
56
+
49
57
  Brokers are not on ``cqrs``. Kafka is
50
58
  ``cqrs.adapters.kafka.kafka_producer_factory`` plus
51
59
  ``cqrs.message_brokers.kafka.KafkaMessageBroker`` (extra ``kafka``). AMQP is
@@ -79,6 +87,11 @@ if typing.TYPE_CHECKING:
79
87
  )
80
88
  from cqrs.models.pydantic import PydanticRequest
81
89
  from cqrs.models.pydantic import PydanticResponse
90
+ from cqrs.inbox.sqlalchemy import (
91
+ SqlAlchemyInboxRepository,
92
+ rebind_inbox_model,
93
+ )
94
+ from cqrs.inbox.sqlmodel import SqlModelInboxRepository
82
95
  from cqrs.outbox.sqlalchemy import (
83
96
  SqlAlchemyOutboxedEventRepository,
84
97
  rebind_outbox_model,
@@ -126,6 +139,9 @@ from cqrs.mediators import (
126
139
  SagaMediator,
127
140
  StreamingRequestMediator,
128
141
  )
142
+ from cqrs.inbox.map import InboxMessageMap
143
+ from cqrs.inbox.processor import InboxProcessor
144
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
129
145
  from cqrs.mapping.outbox import OutboxedEventMap
130
146
  from cqrs.outbox.repository import (
131
147
  EventStatus,
@@ -202,6 +218,13 @@ __all__ = (
202
218
  "OutboxedEventRepository",
203
219
  "SqlAlchemyOutboxedEventRepository",
204
220
  "SqlModelOutboxedEventRepository",
221
+ "InboxRepository",
222
+ "InboxMessage",
223
+ "InboxStatus",
224
+ "InboxMessageMap",
225
+ "InboxProcessor",
226
+ "SqlAlchemyInboxRepository",
227
+ "SqlModelInboxRepository",
205
228
  "EventProducer",
206
229
  "EventSerializer",
207
230
  "EventCodec",
@@ -220,6 +243,7 @@ __all__ = (
220
243
  "Compressor",
221
244
  "ZlibCompressor",
222
245
  "rebind_outbox_model",
246
+ "rebind_inbox_model",
223
247
  "Saga",
224
248
  "SagaStepHandler",
225
249
  "SagaStepResult",
@@ -252,6 +276,11 @@ _LAZY_SQLALCHEMY = {
252
276
  "SqlAlchemyOutboxedEventRepository",
253
277
  ),
254
278
  "rebind_outbox_model": ("cqrs.outbox.sqlalchemy", "rebind_outbox_model"),
279
+ "SqlAlchemyInboxRepository": (
280
+ "cqrs.inbox.sqlalchemy",
281
+ "SqlAlchemyInboxRepository",
282
+ ),
283
+ "rebind_inbox_model": ("cqrs.inbox.sqlalchemy", "rebind_inbox_model"),
255
284
  "DialectAwareType": ("cqrs.sqlalchemy_types", "DialectAwareType"),
256
285
  "DialectTypeHandler": ("cqrs.sqlalchemy_types", "DialectTypeHandler"),
257
286
  "UUIDBinary": ("cqrs.sqlalchemy_types", "UUIDBinary"),
@@ -265,6 +294,10 @@ _LAZY_SQLMODEL = {
265
294
  "cqrs.outbox.sqlmodel",
266
295
  "SqlModelOutboxedEventRepository",
267
296
  ),
297
+ "SqlModelInboxRepository": (
298
+ "cqrs.inbox.sqlmodel",
299
+ "SqlModelInboxRepository",
300
+ ),
268
301
  }
269
302
 
270
303
 
cqrs/inbox/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ Transactional inbox.
3
+
4
+ [AI Instruction]
5
+ Import ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
6
+ ``InboxMessageMap``, and ``InboxProcessor`` from ``cqrs``. SQLAlchemy and
7
+ SQLModel repositories are lazy. Presentation calls ``add``. A worker calls
8
+ ``InboxProcessor``.
9
+ """
10
+
11
+ from cqrs.inbox.map import InboxMessageMap
12
+ from cqrs.inbox.processor import InboxProcessor
13
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
14
+
15
+ __all__ = (
16
+ "InboxMessage",
17
+ "InboxMessageMap",
18
+ "InboxProcessor",
19
+ "InboxRepository",
20
+ "InboxStatus",
21
+ )
cqrs/inbox/map.py ADDED
@@ -0,0 +1,201 @@
1
+ """
2
+ Name registry for inbox messages.
3
+
4
+ [AI Instruction]
5
+ Register each request or event name before ``InboxRepository.add``. JSON is
6
+ the default codec. A second ``register`` of the same name raises ``KeyError``.
7
+ Prefer an instance when tests must not share the process-global map.
8
+ """
9
+
10
+ import functools
11
+ import typing
12
+
13
+ import orjson
14
+
15
+ from cqrs.inbox.repository import InboxEntry
16
+ from cqrs.models.event import IEvent
17
+ from cqrs.models.request import IRequest
18
+
19
+ _KT = typing.TypeVar("_KT")
20
+
21
+
22
+ class _JsonMessageSerializer:
23
+ """JSON codec. Wire format is ``orjson.dumps(message.to_dict())``."""
24
+
25
+ def serialize(self, message: InboxEntry) -> bytes:
26
+ return orjson.dumps(message.to_dict())
27
+
28
+ def deserialize(self, payload: bytes, message_type: typing.Type[_KT]) -> _KT:
29
+ data = orjson.loads(payload)
30
+ if not isinstance(data, dict):
31
+ raise TypeError("inbox payload must be a JSON object")
32
+ return message_type.from_dict(**data) # type: ignore[attr-defined]
33
+
34
+
35
+ class MessageCodec(typing.Protocol):
36
+ """Serialize and deserialize one inbox message type."""
37
+
38
+ def serialize(self, message: InboxEntry) -> bytes: ...
39
+
40
+ def deserialize(self, payload: bytes, message_type: typing.Type[typing.Any]) -> typing.Any: ...
41
+
42
+
43
+ class _bind_self_or_singleton:
44
+ """Class access binds the process singleton; instance access binds that map."""
45
+
46
+ def __init__(self, func: typing.Callable) -> None:
47
+ self.func = func
48
+
49
+ def __get__(
50
+ self,
51
+ obj: typing.Any,
52
+ objtype: type | None = None,
53
+ ) -> typing.Callable:
54
+ target = obj if obj is not None else objtype._singleton() # type: ignore[union-attr]
55
+ return functools.partial(self.func, target)
56
+
57
+
58
+ def message_name(message: InboxEntry) -> str:
59
+ """
60
+ Name stored with the row.
61
+
62
+ Notification events use ``event_name``, the same string the outbox stores.
63
+ Requests and domain events use the class name.
64
+ """
65
+
66
+ event_name = getattr(message, "event_name", None)
67
+ if isinstance(event_name, str) and event_name:
68
+ return event_name
69
+ return type(message).__name__
70
+
71
+
72
+ def _type_matches(message_type: type, registered: typing.Any) -> bool:
73
+ registered_origin = typing.get_origin(registered) or registered
74
+ if message_type is registered:
75
+ return True
76
+ if typing.get_origin(registered) is not None and message_type is registered_origin:
77
+ return True
78
+ if isinstance(registered, type) and issubclass(message_type, registered):
79
+ return True
80
+ return False
81
+
82
+
83
+ class InboxMessageMap:
84
+ """
85
+ Registry of inbox message names to request or event types.
86
+
87
+ Class-level ``register`` / ``get`` use a process-global singleton.
88
+ ``InboxMessageMap()`` creates an isolated registry.
89
+
90
+ [AI Instruction]
91
+ Tell the inbox which Python type belongs to a message name. Register
92
+ before the repository reads or writes that message. JSON is the default.
93
+ A second registration of the same name raises ``KeyError``. Requests and
94
+ domain events are registered under the class name. Notification events
95
+ are registered under ``event_name``.
96
+ """
97
+
98
+ _instance: typing.ClassVar["InboxMessageMap | None"] = None
99
+
100
+ def __init__(self) -> None:
101
+ self._entries: typing.Dict[str, typing.Tuple[typing.Any, MessageCodec | None]] = {}
102
+
103
+ @classmethod
104
+ def _singleton(cls) -> "InboxMessageMap":
105
+ if cls._instance is None:
106
+ cls._instance = cls()
107
+ return cls._instance
108
+
109
+ @_bind_self_or_singleton
110
+ def register(
111
+ self,
112
+ message_name_: str,
113
+ message_type: typing.Type[InboxEntry],
114
+ serializer: MessageCodec | None = None,
115
+ ) -> None:
116
+ """
117
+ Bind ``message_name_`` to a request or event class.
118
+
119
+ [AI Instruction]
120
+ Call once at startup. A second registration of the same name raises
121
+ ``KeyError``. Omit ``serializer`` for JSON.
122
+ """
123
+
124
+ if message_name_ in self._entries:
125
+ raise KeyError(f"Message {message_name_} already registered")
126
+ self._entries[message_name_] = (message_type, serializer)
127
+
128
+ @_bind_self_or_singleton
129
+ def get(self, message_name_: str) -> typing.Any | None:
130
+ """Return the registered type, or ``None`` when the name is unknown."""
131
+
132
+ entry = self._entries.get(message_name_)
133
+ if entry is None:
134
+ return None
135
+ return entry[0]
136
+
137
+ @_bind_self_or_singleton
138
+ def get_serializer(self, message_name_: str) -> MessageCodec | None:
139
+ """Return the codec registered for this name, or ``None`` for JSON."""
140
+
141
+ entry = self._entries.get(message_name_)
142
+ if entry is None:
143
+ return None
144
+ return entry[1]
145
+
146
+ @_bind_self_or_singleton
147
+ def deserialize(self, message_name_: str, payload: bytes) -> InboxEntry:
148
+ """
149
+ Rebuild a request or event from stored bytes.
150
+
151
+ [AI Instruction]
152
+ ``InboxProcessor`` calls this. An unknown name raises ``KeyError``
153
+ and does not return a message.
154
+ """
155
+
156
+ entry = self._entries.get(message_name_)
157
+ if entry is None:
158
+ raise KeyError(f"Unknown message name {message_name_}")
159
+ message_type, serializer = entry
160
+ codec = serializer or _JsonMessageSerializer()
161
+ return codec.deserialize(payload, message_type)
162
+
163
+ @_bind_self_or_singleton
164
+ def payload_for(self, message: InboxEntry) -> typing.Tuple[str, bytes]:
165
+ """
166
+ Return ``(message_name, payload)`` for a registered message.
167
+
168
+ Raises ``TypeError`` when the name is missing or the instance type
169
+ does not match the registration.
170
+ """
171
+
172
+ if not isinstance(message, (IRequest, IEvent)):
173
+ raise TypeError(f"Unsupported inbox message type {type(message)}")
174
+ name = message_name(message)
175
+ registered = self.get(name)
176
+ if registered is None:
177
+ raise TypeError(f"Unknown message name for {name}")
178
+ if not _type_matches(type(message), registered):
179
+ raise TypeError(
180
+ f"Message type {type(message)} does not match registered type {registered}",
181
+ )
182
+ codec = self.get_serializer(name) or _JsonMessageSerializer()
183
+ return name, codec.serialize(message)
184
+
185
+
186
+ def resolve_message_map(
187
+ message_map: "InboxMessageMap | None",
188
+ ) -> InboxMessageMap:
189
+ """Use the given instance, or the process singleton when ``message_map`` is omitted."""
190
+
191
+ if message_map is None or message_map is InboxMessageMap:
192
+ return InboxMessageMap._singleton()
193
+ return message_map
194
+
195
+
196
+ __all__ = (
197
+ "InboxMessageMap",
198
+ "MessageCodec",
199
+ "message_name",
200
+ "resolve_message_map",
201
+ )
cqrs/inbox/memory.py ADDED
@@ -0,0 +1,110 @@
1
+ """In-memory inbox repository for unit tests."""
2
+
3
+ import contextlib
4
+ import dataclasses
5
+ import datetime
6
+ import typing
7
+
8
+ from cqrs.inbox.map import InboxMessageMap, resolve_message_map
9
+ from cqrs.inbox.repository import (
10
+ MAX_FLUSH_COUNTER_VALUE,
11
+ InboxEntry,
12
+ InboxMessage,
13
+ InboxRepository,
14
+ InboxStatus,
15
+ )
16
+
17
+
18
+ class MemoryInboxRepository(InboxRepository):
19
+ """
20
+ Process-local inbox.
21
+
22
+ ``add`` is visible to ``get_many`` on this instance before ``commit``,
23
+ matching a session that has flushed and not committed. ``rollback``
24
+ restores the last ``commit``. ``nested`` snapshots the rows and restores
25
+ them when the block raises.
26
+ """
27
+
28
+ def __init__(self, message_map: InboxMessageMap | None = None) -> None:
29
+ self._message_map = resolve_message_map(message_map)
30
+ self._rows: typing.List[InboxMessage] = []
31
+ self._checkpoint: typing.List[InboxMessage] = []
32
+ self._next_id = 1
33
+
34
+ async def add(
35
+ self,
36
+ messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
37
+ ) -> typing.List[bool]:
38
+ if not messages:
39
+ return []
40
+ stored = {(row.message_id, row.message_name) for row in self._rows}
41
+ results: typing.List[bool] = []
42
+ for message_id, message in messages:
43
+ name, payload = self._message_map.payload_for(message)
44
+ key = (message_id, name)
45
+ if key in stored:
46
+ results.append(False)
47
+ continue
48
+ self._rows.append(
49
+ InboxMessage(
50
+ id=self._next_id,
51
+ message_id=message_id,
52
+ message_name=name,
53
+ status=InboxStatus.NEW,
54
+ flush_counter=0,
55
+ created_at=datetime.datetime.now(),
56
+ payload=payload,
57
+ ),
58
+ )
59
+ self._next_id += 1
60
+ stored.add(key)
61
+ results.append(True)
62
+ return results
63
+
64
+ async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
65
+ selectable = [
66
+ row
67
+ for row in self._rows
68
+ if row.status in (InboxStatus.NEW, InboxStatus.NOT_PROCESSED)
69
+ and row.flush_counter < MAX_FLUSH_COUNTER_VALUE
70
+ ]
71
+ selectable.sort(key=lambda row: row.id)
72
+ return selectable[:batch_size]
73
+
74
+ async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
75
+ for index, row in enumerate(self._rows):
76
+ if row.id != row_id:
77
+ continue
78
+ flush_counter = row.flush_counter
79
+ if new_status is InboxStatus.NOT_PROCESSED:
80
+ flush_counter += 1
81
+ self._rows[index] = dataclasses.replace(
82
+ row,
83
+ status=new_status,
84
+ flush_counter=flush_counter,
85
+ )
86
+ return
87
+
88
+ def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
89
+ return self._nested()
90
+
91
+ @contextlib.asynccontextmanager
92
+ async def _nested(self) -> typing.AsyncIterator[None]:
93
+ snapshot = [dataclasses.replace(row) for row in self._rows]
94
+ next_id = self._next_id
95
+ try:
96
+ yield
97
+ except BaseException:
98
+ self._rows = snapshot
99
+ self._next_id = next_id
100
+ raise
101
+
102
+ async def commit(self) -> None:
103
+ self._checkpoint = [dataclasses.replace(row) for row in self._rows]
104
+
105
+ async def rollback(self) -> None:
106
+ self._rows = [dataclasses.replace(row) for row in self._checkpoint]
107
+ self._next_id = max((row.id for row in self._rows), default=0) + 1
108
+
109
+
110
+ __all__ = ("MemoryInboxRepository",)
@@ -0,0 +1,116 @@
1
+ """
2
+ Read inbox rows and hand them to the mediator that is already registered.
3
+
4
+ [AI Instruction]
5
+ Run ``InboxProcessor`` in a worker process. It does not replace
6
+ ``repository.add`` in a route or a subscriber. ``process`` does not commit.
7
+ The worker commits the business rows and the status together.
8
+ """
9
+
10
+ import functools
11
+ import logging
12
+ import typing
13
+
14
+ from cqrs.inbox.map import InboxMessageMap
15
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
16
+ from cqrs.models.event import IEvent
17
+ from cqrs.models.request import IRequest
18
+
19
+ logger = logging.getLogger("cqrs")
20
+
21
+
22
+ class InboxProcessor:
23
+ """
24
+ Deserialize one inbox row and send it through an existing mediator.
25
+
26
+ A ``Request`` goes to ``RequestMediator.send``. An event goes to
27
+ ``EventMediator.send``. Each row has its own savepoint: success stores
28
+ ``processed`` inside that savepoint, and a failure stores ``not_processed``
29
+ on the outer transaction after the savepoint rolls back.
30
+
31
+ [AI Instruction]
32
+ Construct it with the repository for this batch's session, the same
33
+ ``InboxMessageMap`` that repository uses, and the mediators bootstrap
34
+ already built. The caller owns the session and the commit. Do not add a
35
+ handler whose only job is to write the inbox row. The handler that
36
+ ``send`` runs must not call ``session.commit``.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ repository: InboxRepository,
42
+ message_map: InboxMessageMap,
43
+ request_mediator: typing.Any | None = None,
44
+ event_mediator: typing.Any | None = None,
45
+ ) -> None:
46
+ self.repository = repository
47
+ self._map = message_map
48
+ self._request_mediator = request_mediator
49
+ self._event_mediator = event_mediator
50
+
51
+ async def batch_generator(
52
+ self,
53
+ batch_size: int = 100,
54
+ ) -> typing.AsyncIterator[typing.List[InboxMessage]]:
55
+ """
56
+ Yield batches until the caller stops the loop.
57
+
58
+ [AI Instruction]
59
+ Poll forever. Each yield is one ``get_many`` result from the
60
+ repository passed to ``__init__``. Process every row, then let the
61
+ caller commit. This generator does not commit and does not end on
62
+ an empty batch.
63
+ """
64
+
65
+ while True:
66
+ yield await self.repository.get_many(batch_size=batch_size)
67
+
68
+ async def process(self, row: InboxMessage) -> None:
69
+ """
70
+ Run one row.
71
+
72
+ [AI Instruction]
73
+ Call this for each row from ``get_many``. The repository is the one
74
+ passed to ``__init__``. A handler exception rolls back that row's
75
+ business writes and leaves neighbors in the batch untouched.
76
+ ``not_processed`` increments ``flush_counter``. The row stays
77
+ selectable until the counter reaches five.
78
+ """
79
+
80
+ try:
81
+ message = self._map.deserialize(row.message_name, row.payload)
82
+ async with self.repository.nested():
83
+ await self._dispatch(message)
84
+ await self.repository.update_status(row.id, InboxStatus.PROCESSED)
85
+ except Exception:
86
+ logger.exception(
87
+ "inbox row %s (%s) failed",
88
+ row.id,
89
+ row.message_name,
90
+ )
91
+ await self.repository.update_status(row.id, InboxStatus.NOT_PROCESSED)
92
+
93
+ @typing.overload
94
+ async def _dispatch(self, message: IRequest) -> None: ...
95
+
96
+ @typing.overload
97
+ async def _dispatch(self, message: IEvent) -> None: ...
98
+
99
+ @functools.singledispatchmethod
100
+ async def _dispatch(self, message: typing.Any) -> None:
101
+ raise TypeError(f"Unsupported inbox message type {type(message)}")
102
+
103
+ @_dispatch.register(IRequest) # pyright: ignore[reportFunctionMemberAccess]
104
+ async def _dispatch_request(self, message: IRequest) -> None:
105
+ if self._request_mediator is None:
106
+ raise RuntimeError("RequestMediator is not configured")
107
+ await self._request_mediator.send(message)
108
+
109
+ @_dispatch.register(IEvent) # pyright: ignore[reportFunctionMemberAccess]
110
+ async def _dispatch_event(self, message: IEvent) -> None:
111
+ if self._event_mediator is None:
112
+ raise RuntimeError("EventMediator is not configured")
113
+ await self._event_mediator.send(message)
114
+
115
+
116
+ __all__ = ("InboxProcessor",)
@@ -0,0 +1,149 @@
1
+ """
2
+ Transactional inbox repository protocol.
3
+
4
+ [AI Instruction]
5
+ Presentation calls ``add`` with a batch of ``(message_id, message)`` and
6
+ does not commit inside ``add``. A separate process uses ``get_many`` and
7
+ ``InboxProcessor``. There is no handler and no middleware whose only job
8
+ is to insert the inbox row.
9
+ """
10
+
11
+ import abc
12
+ import contextlib
13
+ import dataclasses
14
+ import datetime
15
+ import enum
16
+ import sys
17
+ import typing
18
+
19
+ from cqrs.models.event import IEvent
20
+ from cqrs.models.request import IRequest
21
+
22
+ if sys.version_info >= (3, 11):
23
+ StrEnum = enum.StrEnum # novm
24
+ else:
25
+
26
+ class StrEnum(str, enum.Enum): # type: ignore[misc]
27
+ """Compatible StrEnum for Python 3.10."""
28
+
29
+ def __str__(self) -> str:
30
+ return self.value
31
+
32
+
33
+ class InboxStatus(StrEnum):
34
+ """
35
+ Lifecycle of one inbox row.
36
+
37
+ Presentation inserts ``NEW``. ``InboxProcessor`` moves a row to
38
+ ``PROCESSED`` when the mediator finishes and to ``NOT_PROCESSED`` when
39
+ that attempt raises. ``NOT_PROCESSED`` increments ``flush_counter``.
40
+ After five failures the row leaves ``get_many``.
41
+ """
42
+
43
+ NEW = "new"
44
+ PROCESSED = "processed"
45
+ NOT_PROCESSED = "not_processed"
46
+
47
+
48
+ MAX_FLUSH_COUNTER_VALUE = 5
49
+
50
+ InboxEntry = typing.Union[IRequest, IEvent]
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class InboxMessage:
55
+ """
56
+ One stored inbox row, returned by ``get_many``.
57
+
58
+ Application code does not construct it. ``payload`` is the bytes written
59
+ by ``add``. ``InboxProcessor`` deserializes them through ``InboxMessageMap``.
60
+ """
61
+
62
+ id: int
63
+ message_id: str
64
+ message_name: str
65
+ status: InboxStatus
66
+ flush_counter: int
67
+ created_at: datetime.datetime
68
+ payload: bytes
69
+
70
+
71
+ class InboxRepository(abc.ABC):
72
+ """
73
+ [AI Instruction]
74
+ Store a command or an event before a worker runs it. ``add`` always takes
75
+ a batch and returns ``list[bool]`` of the same length. ``True`` means the
76
+ row was inserted. ``False`` means ``(message_id, message_name)`` is already
77
+ stored, including earlier in this batch. An empty batch returns ``[]``.
78
+ ``add`` does not commit. The caller commits that session. ``get_many`` and
79
+ ``InboxProcessor.process`` run later, on their own session.
80
+ """
81
+
82
+ @abc.abstractmethod
83
+ async def add(
84
+ self,
85
+ messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
86
+ ) -> typing.List[bool]:
87
+ """
88
+ Insert a batch. One message is a batch of one element.
89
+
90
+ [AI Instruction]
91
+ Pass ``[(message_id, request_or_event)]``. Register every message name
92
+ on the ``InboxMessageMap`` this repository uses before ``add``. A
93
+ duplicate key returns ``False`` and does not abort the outer
94
+ transaction. Do not commit here.
95
+ """
96
+
97
+ @abc.abstractmethod
98
+ async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
99
+ """
100
+ Return up to ``batch_size`` rows that still need work.
101
+
102
+ Rows have status ``new`` or ``not_processed`` and ``flush_counter``
103
+ below five, ordered by ``id`` ascending. SQLAlchemy and SQLModel lock
104
+ those rows with ``FOR UPDATE SKIP LOCKED``. SQLite does not.
105
+
106
+ [AI Instruction]
107
+ Call this from the worker, not from the HTTP route or the subscriber.
108
+ """
109
+
110
+ @abc.abstractmethod
111
+ async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
112
+ """
113
+ Set one row's status.
114
+
115
+ ``NOT_PROCESSED`` increments ``flush_counter``. ``InboxProcessor``
116
+ calls this. Application handlers do not.
117
+ """
118
+
119
+ @abc.abstractmethod
120
+ def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
121
+ """
122
+ Open a savepoint.
123
+
124
+ [AI Instruction]
125
+ ``InboxProcessor`` runs ``send`` and the ``processed`` status update
126
+ inside this block. An exception rolls back only that savepoint.
127
+ """
128
+
129
+ @abc.abstractmethod
130
+ async def commit(self) -> None:
131
+ """
132
+ Commit the current session.
133
+
134
+ [AI Instruction]
135
+ The caller commits the session. ``add`` and ``process`` do not.
136
+ """
137
+
138
+ @abc.abstractmethod
139
+ async def rollback(self) -> None:
140
+ """Drop the current session's uncommitted inbox and business rows."""
141
+
142
+
143
+ __all__ = (
144
+ "MAX_FLUSH_COUNTER_VALUE",
145
+ "InboxEntry",
146
+ "InboxMessage",
147
+ "InboxRepository",
148
+ "InboxStatus",
149
+ )
@@ -0,0 +1,322 @@
1
+ """
2
+ SQLAlchemy transactional inbox.
3
+
4
+ [AI Instruction]
5
+ Import ``SqlAlchemyInboxRepository`` and ``rebind_inbox_model`` from
6
+ ``cqrs``. Both are lazy and need the ``sqlalchemy`` extra. ``add`` takes a
7
+ batch, uses one savepoint per element, and does not commit.
8
+ ``get_many`` locks rows with ``FOR UPDATE SKIP LOCKED`` on PostgreSQL and
9
+ MySQL. SQLite has no skip lock; examples create the table with
10
+ ``create_sqlite_inbox_table`` because ``Identity()`` is not an autoincrement
11
+ on SQLite.
12
+ """
13
+
14
+ import contextlib
15
+ import datetime
16
+ import typing
17
+
18
+ from cqrs.inbox.map import InboxMessageMap, resolve_message_map
19
+ from cqrs.inbox.repository import (
20
+ MAX_FLUSH_COUNTER_VALUE,
21
+ InboxEntry,
22
+ InboxMessage,
23
+ InboxRepository,
24
+ InboxStatus,
25
+ )
26
+
27
+ try:
28
+ import sqlalchemy
29
+ from sqlalchemy import func
30
+ from sqlalchemy.exc import IntegrityError
31
+ from sqlalchemy.orm import DeclarativeMeta, Mapped, mapped_column, registry
32
+
33
+ from cqrs import sqlalchemy_types
34
+ except ImportError:
35
+ raise ImportError(
36
+ "You are trying to use the SQLAlchemy inbox implementation, "
37
+ "but 'sqlalchemy' is not installed. "
38
+ "Please install it using: pip install python-cqrs[sqlalchemy]",
39
+ ) from None
40
+
41
+
42
+ Base = registry().generate_base()
43
+
44
+ DEFAULT_INBOX_TABLE_NAME = "inbox"
45
+
46
+ _SKIP_LOCKED_DIALECTS = frozenset({"postgresql", "mysql", "mariadb"})
47
+
48
+ SQLITE_INBOX_DDL = """
49
+ CREATE TABLE IF NOT EXISTS inbox (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ message_id VARCHAR(255) NOT NULL,
52
+ message_name VARCHAR(255) NOT NULL,
53
+ status VARCHAR(32) NOT NULL,
54
+ flush_counter SMALLINT NOT NULL DEFAULT 0,
55
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
56
+ payload BLOB NOT NULL,
57
+ CONSTRAINT inbox_message_unique_index UNIQUE (message_id, message_name)
58
+ )
59
+ """
60
+
61
+
62
+ def create_sqlite_inbox_table(sync_connection: typing.Any) -> None:
63
+ """
64
+ Create the inbox table on SQLite.
65
+
66
+ ``InboxModel.id`` uses ``Identity()``, which SQLite renders as
67
+ ``BIGINT NOT NULL`` without autoincrement. Examples and SQLite tests call
68
+ this instead of ``Base.metadata.create_all``. MySQL and PostgreSQL use
69
+ ``Base.metadata`` as-is.
70
+ """
71
+
72
+ sync_connection.exec_driver_sql(SQLITE_INBOX_DDL)
73
+
74
+
75
+ class InboxModel(Base):
76
+ __tablename__ = DEFAULT_INBOX_TABLE_NAME
77
+
78
+ __table_args__ = (
79
+ sqlalchemy.UniqueConstraint(
80
+ "message_id",
81
+ "message_name",
82
+ name="inbox_message_unique_index",
83
+ ),
84
+ )
85
+
86
+ id: Mapped[int] = mapped_column(
87
+ sqlalchemy.BigInteger,
88
+ sqlalchemy.Identity(),
89
+ primary_key=True,
90
+ nullable=False,
91
+ autoincrement=True,
92
+ comment="Identity",
93
+ )
94
+ message_id: Mapped[str] = mapped_column(
95
+ sqlalchemy.String(255),
96
+ nullable=False,
97
+ comment="Delivery idempotency id",
98
+ )
99
+ message_name: Mapped[str] = mapped_column(
100
+ sqlalchemy.String(255),
101
+ nullable=False,
102
+ comment="Registered message name",
103
+ )
104
+ status: Mapped[InboxStatus] = mapped_column(
105
+ sqlalchemy.Enum(InboxStatus),
106
+ nullable=False,
107
+ default=InboxStatus.NEW,
108
+ comment="Inbox processing status",
109
+ )
110
+ flush_counter: Mapped[int] = mapped_column(
111
+ sqlalchemy.SmallInteger,
112
+ nullable=False,
113
+ default=0,
114
+ comment="Failed processing attempts",
115
+ )
116
+ created_at: Mapped[datetime.datetime] = mapped_column(
117
+ sqlalchemy.DateTime,
118
+ nullable=False,
119
+ default=datetime.datetime.now,
120
+ server_default=func.now(),
121
+ comment="Row creation timestamp",
122
+ )
123
+ payload: Mapped[bytes] = mapped_column(
124
+ sqlalchemy_types.PayloadBinary,
125
+ nullable=False,
126
+ comment="Serialized request or event",
127
+ )
128
+
129
+ @classmethod
130
+ def get_batch_query(cls, size: int, *, lock: bool = False) -> sqlalchemy.Select:
131
+ query = (
132
+ sqlalchemy.select(cls)
133
+ .where(
134
+ sqlalchemy.and_(
135
+ cls.status.in_(
136
+ [
137
+ InboxStatus.NEW,
138
+ InboxStatus.NOT_PROCESSED,
139
+ ],
140
+ ),
141
+ cls.flush_counter < MAX_FLUSH_COUNTER_VALUE,
142
+ ),
143
+ )
144
+ .order_by(cls.id.asc())
145
+ .limit(size)
146
+ .execution_options(populate_existing=True)
147
+ )
148
+ if lock:
149
+ query = query.with_for_update(skip_locked=True)
150
+ return query
151
+
152
+ @classmethod
153
+ def update_status_query(
154
+ cls,
155
+ row_id: int,
156
+ status: InboxStatus,
157
+ ) -> sqlalchemy.Update:
158
+ values: typing.Dict[str, typing.Any] = {
159
+ "status": status,
160
+ "flush_counter": cls.flush_counter,
161
+ }
162
+ if status == InboxStatus.NOT_PROCESSED:
163
+ values["flush_counter"] += 1
164
+ return sqlalchemy.update(cls).where(cls.id == row_id).values(**values)
165
+
166
+
167
+ class SqlAlchemyInboxRepository(InboxRepository):
168
+ """
169
+ Inbox on a SQLAlchemy ``AsyncSession``.
170
+
171
+ [AI Instruction]
172
+ Construct it with the presentation or worker session, not a session
173
+ factory. ``add`` does not commit. Install ``python-cqrs[sqlalchemy]``.
174
+ Register every message name on ``InboxMessageMap`` before the first
175
+ write, including JSON.
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ session: typing.Any,
181
+ *,
182
+ message_map: InboxMessageMap | None = None,
183
+ model: typing.Any = None,
184
+ ) -> None:
185
+ self.session = session
186
+ self._message_map = resolve_message_map(message_map)
187
+ self._model = InboxModel if model is None else model
188
+
189
+ async def add(
190
+ self,
191
+ messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
192
+ ) -> typing.List[bool]:
193
+ if not messages:
194
+ return []
195
+ await self._prepare_savepoint()
196
+ results: typing.List[bool] = []
197
+ seen: typing.Set[typing.Tuple[str, str]] = set()
198
+ for message_id, message in messages:
199
+ name, payload = self._message_map.payload_for(message)
200
+ key = (message_id, name)
201
+ if key in seen:
202
+ results.append(False)
203
+ continue
204
+ try:
205
+ async with self.session.begin_nested():
206
+ self.session.add(
207
+ self._model(
208
+ message_id=message_id,
209
+ message_name=name,
210
+ payload=payload,
211
+ status=InboxStatus.NEW,
212
+ flush_counter=0,
213
+ created_at=datetime.datetime.now(),
214
+ ),
215
+ )
216
+ await self.session.flush()
217
+ except IntegrityError:
218
+ results.append(False)
219
+ continue
220
+ seen.add(key)
221
+ results.append(True)
222
+ return results
223
+
224
+ async def _prepare_savepoint(self) -> None:
225
+ """Keep a SQLite savepoint inside the caller's transaction.
226
+
227
+ sqlite3 commits when ``RELEASE`` runs on a savepoint that started the
228
+ transaction. Emit ``BEGIN`` first so ``add`` and ``nested`` can roll
229
+ back with the outer session. PostgreSQL and MySQL already do this.
230
+ """
231
+
232
+ bind = self.session.get_bind()
233
+ if getattr(getattr(bind, "dialect", None), "name", "") != "sqlite":
234
+ return
235
+ connection = await self.session.connection()
236
+ raw = await connection.get_raw_connection()
237
+ driver = raw.driver_connection
238
+ sqlite_connection = getattr(driver, "_conn", driver)
239
+ if getattr(sqlite_connection, "in_transaction", None) is False:
240
+ await connection.exec_driver_sql("BEGIN")
241
+
242
+ def _skip_locked(self) -> bool:
243
+ bind = self.session.get_bind()
244
+ return bind.dialect.name in _SKIP_LOCKED_DIALECTS
245
+
246
+ def _to_message(self, row: typing.Any) -> InboxMessage:
247
+ status = row.status
248
+ if not isinstance(status, InboxStatus):
249
+ if isinstance(status, str) and status in InboxStatus.__members__:
250
+ status = InboxStatus[status]
251
+ else:
252
+ status = InboxStatus(status)
253
+ payload = row.payload
254
+ if not isinstance(payload, bytes):
255
+ payload = bytes(payload)
256
+ return InboxMessage(
257
+ id=row.id,
258
+ message_id=row.message_id,
259
+ message_name=row.message_name,
260
+ status=status,
261
+ flush_counter=row.flush_counter,
262
+ created_at=row.created_at,
263
+ payload=payload,
264
+ )
265
+
266
+ async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
267
+ query = self._model.get_batch_query(batch_size, lock=self._skip_locked())
268
+ rows = (await self.session.execute(query)).scalars().all()
269
+ return [self._to_message(row) for row in rows]
270
+
271
+ async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
272
+ await self.session.execute(
273
+ statement=self._model.update_status_query(row_id, new_status),
274
+ )
275
+
276
+ def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
277
+ return self._nested()
278
+
279
+ @contextlib.asynccontextmanager
280
+ async def _nested(self) -> typing.AsyncIterator[None]:
281
+ await self._prepare_savepoint()
282
+ async with self.session.begin_nested():
283
+ yield
284
+
285
+ async def commit(self) -> None:
286
+ await self.session.commit()
287
+
288
+ async def rollback(self) -> None:
289
+ await self.session.rollback()
290
+
291
+
292
+ def rebind_inbox_model(
293
+ model: typing.Any,
294
+ new_base: DeclarativeMeta,
295
+ table_name: typing.Text | None = None,
296
+ ) -> None:
297
+ """
298
+ Attach the inbox model to the application's declarative base.
299
+
300
+ [AI Instruction]
301
+ Call it once at startup, before ``create_all``, before migrations, and
302
+ before the first inbox query. Pass a table name when the default
303
+ ``inbox`` collides with an existing table.
304
+ """
305
+
306
+ model.__bases__ = (new_base,)
307
+ model.__table__.name = table_name or model.__table__.name
308
+ new_base.metadata._add_table(
309
+ model.__table__.name,
310
+ model.__table__.schema,
311
+ model.__table__,
312
+ )
313
+
314
+
315
+ __all__ = (
316
+ "DEFAULT_INBOX_TABLE_NAME",
317
+ "Base",
318
+ "InboxModel",
319
+ "SqlAlchemyInboxRepository",
320
+ "create_sqlite_inbox_table",
321
+ "rebind_inbox_model",
322
+ )
cqrs/inbox/sqlmodel.py ADDED
@@ -0,0 +1,164 @@
1
+ """
2
+ SQLModel inbox: same table, queries, and repository behavior as SQLAlchemy.
3
+
4
+ [AI Instruction]
5
+ Import ``SqlModelInboxRepository`` from ``cqrs``. It is lazy and needs the
6
+ ``sqlmodel`` extra. Pass a ``sqlmodel.ext.asyncio.session.AsyncSession``.
7
+ """
8
+
9
+ import datetime
10
+ import typing
11
+
12
+ from cqrs.inbox.map import InboxMessageMap
13
+ from cqrs.inbox.repository import InboxStatus
14
+
15
+ try:
16
+ from cqrs.inbox.sqlalchemy import (
17
+ DEFAULT_INBOX_TABLE_NAME,
18
+ InboxModel as SqlAlchemyInboxModel,
19
+ SqlAlchemyInboxRepository,
20
+ rebind_inbox_model,
21
+ )
22
+
23
+ import sqlalchemy
24
+ from sqlalchemy import func
25
+ from sqlmodel import Field
26
+ from sqlmodel.ext.asyncio.session import AsyncSession
27
+
28
+ from cqrs import sqlalchemy_types
29
+ from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
30
+ except ImportError:
31
+ raise ImportError(
32
+ "You are trying to use the SQLModel inbox implementation, "
33
+ "but 'sqlmodel' is not installed. "
34
+ "Please install it using: pip install python-cqrs[sqlmodel]",
35
+ ) from None
36
+
37
+ Base = CqrsSQLModel
38
+
39
+
40
+ class InboxModel(Base, table=True):
41
+ __tablename__ = DEFAULT_INBOX_TABLE_NAME # type: ignore[assignment]
42
+
43
+ __table_args__ = (
44
+ sqlalchemy.UniqueConstraint(
45
+ "message_id",
46
+ "message_name",
47
+ name="inbox_message_unique_index",
48
+ ),
49
+ )
50
+
51
+ id: int | None = Field(
52
+ default=None,
53
+ sa_column=sqlalchemy.Column(
54
+ sqlalchemy.BigInteger,
55
+ sqlalchemy.Identity(),
56
+ primary_key=True,
57
+ nullable=False,
58
+ autoincrement=True,
59
+ comment="Identity",
60
+ ),
61
+ )
62
+ message_id: str = Field(
63
+ sa_column=sqlalchemy.Column(
64
+ sqlalchemy.String(255),
65
+ nullable=False,
66
+ comment="Delivery idempotency id",
67
+ ),
68
+ )
69
+ message_name: str = Field(
70
+ sa_column=sqlalchemy.Column(
71
+ sqlalchemy.String(255),
72
+ nullable=False,
73
+ comment="Registered message name",
74
+ ),
75
+ )
76
+ status: InboxStatus = Field(
77
+ default=InboxStatus.NEW,
78
+ sa_column=sqlalchemy.Column(
79
+ sqlalchemy.Enum(InboxStatus),
80
+ nullable=False,
81
+ default=InboxStatus.NEW,
82
+ comment="Inbox processing status",
83
+ ),
84
+ )
85
+ flush_counter: int = Field(
86
+ default=0,
87
+ sa_column=sqlalchemy.Column(
88
+ sqlalchemy.SmallInteger,
89
+ nullable=False,
90
+ default=0,
91
+ comment="Failed processing attempts",
92
+ ),
93
+ )
94
+ created_at: datetime.datetime | None = Field(
95
+ default=None,
96
+ sa_column=sqlalchemy.Column(
97
+ sqlalchemy.DateTime,
98
+ nullable=False,
99
+ server_default=func.now(),
100
+ comment="Row creation timestamp",
101
+ ),
102
+ )
103
+ payload: bytes = Field(
104
+ sa_column=sqlalchemy.Column(
105
+ sqlalchemy_types.PayloadBinary,
106
+ nullable=False,
107
+ comment="Serialized request or event",
108
+ ),
109
+ )
110
+
111
+ @classmethod
112
+ def get_batch_query(
113
+ cls,
114
+ size: int,
115
+ *,
116
+ lock: bool = False,
117
+ ) -> sqlalchemy.Select:
118
+ query = SqlAlchemyInboxModel.get_batch_query
119
+ return query.__func__(cls, size, lock=lock) # type: ignore[attr-defined]
120
+
121
+ @classmethod
122
+ def update_status_query(
123
+ cls,
124
+ row_id: int,
125
+ status: InboxStatus,
126
+ ) -> sqlalchemy.Update:
127
+ query = SqlAlchemyInboxModel.update_status_query
128
+ return query.__func__(cls, row_id, status) # type: ignore[attr-defined]
129
+
130
+
131
+ class SqlModelInboxRepository(SqlAlchemyInboxRepository):
132
+ """
133
+ Inbox on a SQLModel ``AsyncSession``.
134
+
135
+ Behavior matches ``SqlAlchemyInboxRepository``: one savepoint per batch
136
+ element, ``get_many`` with ``FOR UPDATE SKIP LOCKED`` on PostgreSQL and
137
+ MySQL, and no commit inside ``add``.
138
+
139
+ [AI Instruction]
140
+ Install ``python-cqrs[sqlmodel]``. Pass the SQLModel session the
141
+ presentation or the worker already opened.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ session: AsyncSession,
147
+ *,
148
+ message_map: InboxMessageMap | None = None,
149
+ ) -> None:
150
+ super().__init__(
151
+ typing.cast(typing.Any, SqlAlchemyExecuteSession(session)),
152
+ message_map=message_map,
153
+ model=InboxModel,
154
+ )
155
+
156
+
157
+ __all__ = (
158
+ "AsyncSession",
159
+ "Base",
160
+ "DEFAULT_INBOX_TABLE_NAME",
161
+ "InboxModel",
162
+ "SqlModelInboxRepository",
163
+ "rebind_inbox_model",
164
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-cqrs
3
- Version: 5.2.0
3
+ Version: 5.3.0
4
4
  Summary: Event-Driven Architecture Framework for Distributed Systems
5
5
  Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
6
6
  Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
17
18
  Requires-Python: >=3.10
18
19
  Description-Content-Type: text/markdown
19
20
  License-File: LICENSE
@@ -1,4 +1,4 @@
1
- cqrs/__init__.py,sha256=7jcpmJKHRqfRO1szpLXn0UepRQq1tsH_9wbvCss21Po,9808
1
+ cqrs/__init__.py,sha256=X5sbSFabkB9rStBlpYV1lKEkqypnjoObaiX6Is4dsAQ,11091
2
2
  cqrs/_dataclass_utils.py,sha256=OqKF3Gifarcj7rjFWUe60BcBrpt0NePgiXbbiuigMdk,3555
3
3
  cqrs/circuit_breaker.py,sha256=-6d01MaI-isZ4UDWKPQlXxTLe4ggr3N3ZLDOpoTQQ1c,2850
4
4
  cqrs/generic_utils.py,sha256=UMFUHofmAtKnZSWqrOGSZFhG3K-8nZotO7WGIFyCPhg,4476
@@ -44,6 +44,13 @@ cqrs/handlers/cor.py,sha256=3nKX4m7gtoNzSe_AGrJ9_wF-fhwrGAQKabNAX8vW1yM,5012
44
44
  cqrs/handlers/event.py,sha256=_6oR4FpxpCINqYjlUMsseRahkcDcwFJBwNO-CewU_E0,2564
45
45
  cqrs/handlers/request.py,sha256=VuBzugiX2fVBU04Pl45xGpa7TrKdQJKLKmAj3qtZTSo,7431
46
46
  cqrs/handlers/saga.py,sha256=3FW7zkoo31IOKfqbstAaKltpLOR6Y3cSvLog5bYyboQ,8775
47
+ cqrs/inbox/__init__.py,sha256=ncYMqAZRorhtFHEn-o30RHuKzMB3ErWjefUHUNyyZ38,570
48
+ cqrs/inbox/map.py,sha256=CZ8X7ExmhK1ip7nzXANVodzaSB7ufyaFYPqZdi7j-sI,6669
49
+ cqrs/inbox/memory.py,sha256=ypCGKLfb9Blmc2-lX5cjUsz02w4TYr4H3khKk9GR52E,3663
50
+ cqrs/inbox/processor.py,sha256=q4BrTLPh97DgHlaAC6SMmPIlgjmmVayzfgZFgsvM0UU,4282
51
+ cqrs/inbox/repository.py,sha256=aAPvzJlCQROmyEHsK94jOkDljTun-N_u9X7J8wtg15M,4355
52
+ cqrs/inbox/sqlalchemy.py,sha256=Gc76C1cpGxZTQ-ccJgBkmQXbIDTPqRoZor4lo0Gq6NI,10356
53
+ cqrs/inbox/sqlmodel.py,sha256=2kymL9l8lud13rh-Ibmke4kA_lHeSqg90EtMe0u5EyE,4530
47
54
  cqrs/mapping/__init__.py,sha256=Kutj0hdjXCyO0L_gm4fMtE2jojOZeJRIidcqRsBqyKY,213
48
55
  cqrs/mapping/events.py,sha256=W5ms16-t8dF-DufZRd79g-p5m7RYRSf9nG3z_8W5o08,3257
49
56
  cqrs/mapping/outbox.py,sha256=x9ZNg5orp6mt_kqNO9XFTGZOWYBUeh7ff-D_T3I1hfc,5244
@@ -99,8 +106,8 @@ cqrs/serializers/default.py,sha256=USVd1dwtgMgMd2FlK8BHtc7B61myzWcosHR4VkYEKB4,2
99
106
  cqrs/serializers/json.py,sha256=UxHQs_NdP5M44gH6ZZ2CkXtxoTTOdhfwG12N7gxP--0,884
100
107
  cqrs/serializers/protobuf.py,sha256=T6TO6cQ3c6XHK6YGG9Rr198EHIVP9isawNa_VLV78gI,1966
101
108
  cqrs/serializers/protocol.py,sha256=xYimT7tjzpDvY86aSncSZagqgf-xJiy2kmdjC1cxz_Q,1676
102
- python_cqrs-5.2.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
103
- python_cqrs-5.2.0.dist-info/METADATA,sha256=LQLpEktszddjHv3Q1opzm2sNGvMJeUcSZoHwot7Da3E,51110
104
- python_cqrs-5.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
105
- python_cqrs-5.2.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
106
- python_cqrs-5.2.0.dist-info/RECORD,,
109
+ python_cqrs-5.3.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
110
+ python_cqrs-5.3.0.dist-info/METADATA,sha256=5othZeBCvSj22dJC3oxcXFqXmERFreCuYvMz2SgFZ7E,51161
111
+ python_cqrs-5.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
112
+ python_cqrs-5.3.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
113
+ python_cqrs-5.3.0.dist-info/RECORD,,