python-cqrs 5.0.0__py3-none-any.whl → 5.1.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
@@ -12,6 +12,7 @@ if typing.TYPE_CHECKING:
12
12
  SqlAlchemyOutboxedEventRepository,
13
13
  rebind_outbox_model,
14
14
  )
15
+ from cqrs.outbox.sqlmodel import SqlModelOutboxedEventRepository
15
16
  from cqrs.sqlalchemy_types import (
16
17
  Binary16,
17
18
  DialectAwareType,
@@ -129,6 +130,7 @@ __all__ = (
129
130
  "PydanticResponse",
130
131
  "OutboxedEventRepository",
131
132
  "SqlAlchemyOutboxedEventRepository",
133
+ "SqlModelOutboxedEventRepository",
132
134
  "EventProducer",
133
135
  "EventSerializer",
134
136
  "EventCodec",
@@ -187,6 +189,13 @@ _LAZY_SQLALCHEMY = {
187
189
  "PayloadBinary": ("cqrs.sqlalchemy_types", "PayloadBinary"),
188
190
  }
189
191
 
192
+ _LAZY_SQLMODEL = {
193
+ "SqlModelOutboxedEventRepository": (
194
+ "cqrs.outbox.sqlmodel",
195
+ "SqlModelOutboxedEventRepository",
196
+ ),
197
+ }
198
+
190
199
 
191
200
  def __getattr__(name: str) -> typing.Any:
192
201
  if name in _LAZY_PYDANTIC:
@@ -201,4 +210,9 @@ def __getattr__(name: str) -> typing.Any:
201
210
  # ImportError message is raised by the sqlalchemy modules themselves.
202
211
  module = __import__(module_name, fromlist=[attr])
203
212
  return getattr(module, attr)
213
+ if name in _LAZY_SQLMODEL:
214
+ module_name, attr = _LAZY_SQLMODEL[name]
215
+ # ImportError message is raised by the sqlmodel modules themselves.
216
+ module = __import__(module_name, fromlist=[attr])
217
+ return getattr(module, attr)
204
218
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
cqrs/outbox/sqlalchemy.py CHANGED
@@ -173,11 +173,13 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
173
173
  *,
174
174
  serializer: EventCodec | None = None,
175
175
  event_map: map.OutboxedEventMap | None = None,
176
+ model: typing.Any = None,
176
177
  ):
177
178
  self.session = session
178
179
  self._compressor = compressor
179
180
  self._serializer = serializer or JsonEventSerializer()
180
181
  self._event_map = event_map if event_map is not None else map.OutboxedEventMap
182
+ self._model = OutboxModel if model is None else model
181
183
 
182
184
  def _resolve_codec(self, event_name: str) -> EventCodec:
183
185
  return self._event_map.get_serializer(event_name) or self._serializer
@@ -211,7 +213,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
211
213
  bytes_payload = self._compressor.compress(bytes_payload)
212
214
 
213
215
  self.session.add(
214
- OutboxModel(
216
+ self._model(
215
217
  event_id=event.event_id,
216
218
  event_id_bin=event.event_id.bytes,
217
219
  event_name=event.event_name,
@@ -221,7 +223,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
221
223
  ),
222
224
  )
223
225
 
224
- def _process_events(self, model: OutboxModel) -> repository.OutboxedEvent | None:
226
+ def _process_events(self, model: typing.Any) -> repository.OutboxedEvent | None:
225
227
  event_dict = model.row_to_dict()
226
228
  event_name = event_dict["event_name"]
227
229
 
@@ -260,8 +262,8 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
260
262
  batch_size: int = 100,
261
263
  topic: typing.Text | None = None,
262
264
  ) -> typing.List[repository.OutboxedEvent]:
263
- events: typing.Sequence[OutboxModel] = (
264
- (await self.session.execute(OutboxModel.get_batch_query(batch_size, topic))).scalars().all()
265
+ events: typing.Sequence[typing.Any] = (
266
+ (await self.session.execute(self._model.get_batch_query(batch_size, topic))).scalars().all()
265
267
  )
266
268
 
267
269
  result = []
@@ -285,7 +287,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
285
287
  new_status: repository.EventStatus,
286
288
  ) -> None:
287
289
  await self.session.execute(
288
- statement=OutboxModel.update_status_query(outboxed_event_id, new_status),
290
+ statement=self._model.update_status_query(outboxed_event_id, new_status),
289
291
  )
290
292
 
291
293
  async def commit(self):
@@ -0,0 +1,189 @@
1
+ """SQLModel outbox: same table, queries, and repository behavior as SQLAlchemy."""
2
+
3
+ import datetime
4
+ import typing
5
+ import uuid
6
+
7
+ from cqrs import compressors
8
+ from cqrs.mapping import outbox as map
9
+ from cqrs.outbox import repository
10
+ from cqrs.serializers.protocol import EventCodec
11
+
12
+ try:
13
+ from cqrs.outbox.sqlalchemy import (
14
+ MAX_FLUSH_COUNTER_VALUE,
15
+ BinaryUUID,
16
+ OutboxModel as SqlAlchemyOutboxModel,
17
+ SqlAlchemyOutboxedEventRepository,
18
+ rebind_outbox_model,
19
+ )
20
+
21
+ import sqlalchemy
22
+ from sqlalchemy import func
23
+ from sqlmodel import Field
24
+ from sqlmodel.ext.asyncio.session import AsyncSession
25
+
26
+ from cqrs import sqlalchemy_types
27
+ from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
28
+ except ImportError:
29
+ raise ImportError(
30
+ "You are trying to use the SQLModel outbox implementation, "
31
+ "but 'sqlmodel' is not installed. "
32
+ "Please install it using: pip install python-cqrs[sqlmodel]",
33
+ ) from None
34
+
35
+ Base = CqrsSQLModel
36
+
37
+ DEFAULT_OUTBOX_TABLE_NAME = "outbox"
38
+
39
+
40
+ class OutboxModel(Base, table=True):
41
+ __tablename__ = DEFAULT_OUTBOX_TABLE_NAME # type: ignore[assignment]
42
+
43
+ __table_args__ = (
44
+ sqlalchemy.UniqueConstraint(
45
+ "event_id_bin",
46
+ "event_name",
47
+ name="event_id_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
+ event_id: uuid.UUID = Field(
63
+ sa_column=sqlalchemy.Column(
64
+ sqlalchemy_types.UUIDBinary,
65
+ nullable=False,
66
+ comment="Event idempotency id",
67
+ ),
68
+ )
69
+ event_id_bin: bytes = Field(
70
+ sa_column=sqlalchemy.Column(
71
+ sqlalchemy_types.Binary16,
72
+ nullable=False,
73
+ comment="Event idempotency id in 16 bit presentation",
74
+ ),
75
+ )
76
+ event_status: repository.EventStatus = Field(
77
+ default=repository.EventStatus.NEW,
78
+ sa_column=sqlalchemy.Column(
79
+ sqlalchemy.Enum(repository.EventStatus),
80
+ nullable=False,
81
+ default=repository.EventStatus.NEW,
82
+ comment="Event producing 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="Event producing flush counter",
92
+ ),
93
+ )
94
+ event_name: str = Field(
95
+ sa_column=sqlalchemy.Column(
96
+ sqlalchemy.String(255),
97
+ nullable=False,
98
+ comment="Event name",
99
+ ),
100
+ )
101
+ topic: str = Field(
102
+ default="",
103
+ sa_column=sqlalchemy.Column(
104
+ sqlalchemy.String(255),
105
+ nullable=False,
106
+ default="",
107
+ comment="Event topic",
108
+ ),
109
+ )
110
+ created_at: datetime.datetime | None = Field(
111
+ default=None,
112
+ sa_column=sqlalchemy.Column(
113
+ sqlalchemy.DateTime,
114
+ nullable=False,
115
+ server_default=func.now(),
116
+ comment="Event creation timestamp",
117
+ ),
118
+ )
119
+ payload: bytes = Field(
120
+ sa_column=sqlalchemy.Column(
121
+ sqlalchemy_types.PayloadBinary,
122
+ nullable=False,
123
+ comment="Event payload",
124
+ ),
125
+ )
126
+
127
+ def row_to_dict(self) -> typing.Dict[typing.Text, typing.Any]:
128
+ table = typing.cast(typing.Any, self).__table__
129
+ return {column.name: getattr(self, column.name) for column in table.columns}
130
+
131
+ @classmethod
132
+ def get_batch_query(
133
+ cls,
134
+ size: int,
135
+ topic: typing.Text | None = None,
136
+ ) -> sqlalchemy.Select:
137
+ query = SqlAlchemyOutboxModel.get_batch_query
138
+ return query.__func__(cls, size, topic) # type: ignore[attr-defined]
139
+
140
+ @classmethod
141
+ def update_status_query(
142
+ cls,
143
+ outboxed_event_id: int,
144
+ status: repository.EventStatus,
145
+ ) -> sqlalchemy.Update:
146
+ query = SqlAlchemyOutboxModel.update_status_query
147
+ return query.__func__(cls, outboxed_event_id, status) # type: ignore[attr-defined]
148
+
149
+ @classmethod
150
+ def status_sorting_case(cls) -> sqlalchemy.Case:
151
+ query = SqlAlchemyOutboxModel.status_sorting_case
152
+ return query.__func__(cls) # type: ignore[attr-defined]
153
+
154
+
155
+ class SqlModelOutboxedEventRepository(SqlAlchemyOutboxedEventRepository):
156
+ """Outbox repository that stores rows as SQLModel ``OutboxModel`` instances.
157
+
158
+ Pass a ``sqlmodel.ext.asyncio.session.AsyncSession``. Behavior matches
159
+ ``SqlAlchemyOutboxedEventRepository``: the same batch query, flush budget,
160
+ status ordering, codecs, and compressor hooks.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ session: AsyncSession,
166
+ compressor: compressors.Compressor | None = None,
167
+ *,
168
+ serializer: EventCodec | None = None,
169
+ event_map: map.OutboxedEventMap | None = None,
170
+ ):
171
+ super().__init__(
172
+ typing.cast(typing.Any, SqlAlchemyExecuteSession(session)),
173
+ compressor,
174
+ serializer=serializer,
175
+ event_map=event_map,
176
+ model=OutboxModel,
177
+ )
178
+
179
+
180
+ __all__ = (
181
+ "AsyncSession",
182
+ "Base",
183
+ "BinaryUUID",
184
+ "DEFAULT_OUTBOX_TABLE_NAME",
185
+ "MAX_FLUSH_COUNTER_VALUE",
186
+ "OutboxModel",
187
+ "SqlModelOutboxedEventRepository",
188
+ "rebind_outbox_model",
189
+ )
@@ -145,14 +145,23 @@ class SagaLogModel(Base):
145
145
  class _SqlAlchemySagaStorageRun(SagaStorageRun):
146
146
  """Scoped run: one session, no commit inside methods; caller calls commit()."""
147
147
 
148
- def __init__(self, session: AsyncSession) -> None:
148
+ def __init__(
149
+ self,
150
+ session: AsyncSession,
151
+ execution_model: type[typing.Any] = SagaExecutionModel,
152
+ log_model: type[typing.Any] = SagaLogModel,
153
+ ) -> None:
149
154
  """
150
155
  Initialize the run wrapper with an async SQLAlchemy session.
151
156
 
152
157
  Parameters:
153
158
  session (AsyncSession): The AsyncSession instance scoped to this run, used for all database operations.
159
+ execution_model: Mapped class for saga execution rows. Defaults to the SQLAlchemy model.
160
+ log_model: Mapped class for saga log rows. Defaults to the SQLAlchemy model.
154
161
  """
155
162
  self._session = session
163
+ self._execution_model = execution_model
164
+ self._log_model = log_model
156
165
 
157
166
  async def create_saga(
158
167
  self,
@@ -170,7 +179,7 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
170
179
  name (str): Human-readable name of the saga.
171
180
  context (dict[str, Any]): Initial saga context to be stored (will be serialized to the model's JSON column).
172
181
  """
173
- execution = SagaExecutionModel(
182
+ execution = self._execution_model(
174
183
  id=saga_id,
175
184
  name=name,
176
185
  status=SagaStatus.PENDING,
@@ -197,20 +206,20 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
197
206
  Raises:
198
207
  SagaConcurrencyError: If an optimistic version check fails (indicating a concurrent modification) or if the saga does not exist when a version was supplied.
199
208
  """
200
- stmt = sqlalchemy.update(SagaExecutionModel).where(
201
- SagaExecutionModel.id == saga_id,
209
+ stmt = sqlalchemy.update(self._execution_model).where(
210
+ self._execution_model.id == saga_id,
202
211
  )
203
212
  if current_version is not None:
204
- stmt = stmt.where(SagaExecutionModel.version == current_version)
213
+ stmt = stmt.where(self._execution_model.version == current_version)
205
214
  stmt = stmt.values(
206
215
  context=context,
207
- version=SagaExecutionModel.version + 1,
216
+ version=self._execution_model.version + 1,
208
217
  )
209
218
  result = await self._session.execute(stmt)
210
219
  if result.rowcount == 0: # type: ignore[attr-defined]
211
220
  if current_version is not None:
212
- check_stmt = sqlalchemy.select(SagaExecutionModel.id).where(
213
- SagaExecutionModel.id == saga_id,
221
+ check_stmt = sqlalchemy.select(self._execution_model.id).where(
222
+ self._execution_model.id == saga_id,
214
223
  )
215
224
  check_result = await self._session.execute(check_stmt)
216
225
  if check_result.scalar_one_or_none():
@@ -240,11 +249,11 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
240
249
  SagaConcurrencyError: If no row was updated (saga does not exist or was modified concurrently).
241
250
  """
242
251
  result = await self._session.execute(
243
- sqlalchemy.update(SagaExecutionModel)
244
- .where(SagaExecutionModel.id == saga_id)
252
+ sqlalchemy.update(self._execution_model)
253
+ .where(self._execution_model.id == saga_id)
245
254
  .values(
246
255
  status=status,
247
- version=SagaExecutionModel.version + 1,
256
+ version=self._execution_model.version + 1,
248
257
  ),
249
258
  )
250
259
  if result.rowcount == 0: # pyright: ignore[reportAttributeAccessIssue]
@@ -270,7 +279,7 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
270
279
  status (SagaStepStatus): The step's outcome status.
271
280
  details (str | None): Optional free-form details or error message associated with the step.
272
281
  """
273
- log_entry = SagaLogModel(
282
+ log_entry = self._log_model(
274
283
  saga_id=saga_id,
275
284
  step_name=step_name,
276
285
  action=action,
@@ -298,8 +307,8 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
298
307
  Raises:
299
308
  ValueError: If no saga with the given id exists.
300
309
  """
301
- stmt = sqlalchemy.select(SagaExecutionModel).where(
302
- SagaExecutionModel.id == saga_id,
310
+ stmt = sqlalchemy.select(self._execution_model).where(
311
+ self._execution_model.id == saga_id,
303
312
  )
304
313
  if read_for_update:
305
314
  stmt = stmt.with_for_update()
@@ -330,7 +339,9 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
330
339
  is normalized to UTC if not already timezone-aware.
331
340
  """
332
341
  result = await self._session.execute(
333
- sqlalchemy.select(SagaLogModel).where(SagaLogModel.saga_id == saga_id).order_by(SagaLogModel.created_at),
342
+ sqlalchemy.select(self._log_model)
343
+ .where(self._log_model.saga_id == saga_id)
344
+ .order_by(self._log_model.created_at),
334
345
  )
335
346
  rows = result.scalars().all()
336
347
  return [
@@ -367,14 +378,24 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
367
378
 
368
379
 
369
380
  class SqlAlchemySagaStorage(ISagaStorage):
370
- def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
381
+ def __init__(
382
+ self,
383
+ session_factory: async_sessionmaker[AsyncSession],
384
+ *,
385
+ execution_model: type[typing.Any] = SagaExecutionModel,
386
+ log_model: type[typing.Any] = SagaLogModel,
387
+ ):
371
388
  """
372
389
  Initialize the SQLAlchemy-based saga storage with a factory for creating async sessions.
373
390
 
374
391
  Parameters:
375
392
  session_factory (async_sessionmaker[AsyncSession]): Factory that produces new AsyncSession instances used for each storage run and operation.
393
+ execution_model: Mapped class for saga execution rows. Defaults to the SQLAlchemy model.
394
+ log_model: Mapped class for saga log rows. Defaults to the SQLAlchemy model.
376
395
  """
377
396
  self.session_factory = session_factory
397
+ self._execution_model = execution_model
398
+ self._log_model = log_model
378
399
 
379
400
  def create_run(
380
401
  self,
@@ -391,7 +412,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
391
412
  @contextlib.asynccontextmanager
392
413
  async def _run() -> typing.AsyncGenerator[SagaStorageRun, None]:
393
414
  async with self.session_factory() as session:
394
- run = _SqlAlchemySagaStorageRun(session)
415
+ run = _SqlAlchemySagaStorageRun(
416
+ session,
417
+ self._execution_model,
418
+ self._log_model,
419
+ )
395
420
  try:
396
421
  yield run
397
422
  except BaseException:
@@ -422,7 +447,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
422
447
  """
423
448
  async with self.session_factory() as session:
424
449
  try:
425
- execution = SagaExecutionModel(
450
+ execution = self._execution_model(
426
451
  id=saga_id,
427
452
  name=name,
428
453
  status=SagaStatus.PENDING,
@@ -444,14 +469,14 @@ class SqlAlchemySagaStorage(ISagaStorage):
444
469
  ) -> None:
445
470
  async with self.session_factory() as session:
446
471
  try:
447
- stmt = sqlalchemy.update(SagaExecutionModel).where(
448
- SagaExecutionModel.id == saga_id,
472
+ stmt = sqlalchemy.update(self._execution_model).where(
473
+ self._execution_model.id == saga_id,
449
474
  )
450
475
  if current_version is not None:
451
- stmt = stmt.where(SagaExecutionModel.version == current_version)
476
+ stmt = stmt.where(self._execution_model.version == current_version)
452
477
  stmt = stmt.values(
453
478
  context=context,
454
- version=SagaExecutionModel.version + 1,
479
+ version=self._execution_model.version + 1,
455
480
  )
456
481
 
457
482
  result = await session.execute(stmt)
@@ -462,8 +487,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
462
487
  # But for now, we assume if rowcount is 0 and we checked version, it's concurrency
463
488
  if current_version is not None:
464
489
  # Double check if saga exists
465
- check_stmt = sqlalchemy.select(SagaExecutionModel.id).where(
466
- SagaExecutionModel.id == saga_id,
490
+ check_stmt = sqlalchemy.select(self._execution_model.id).where(
491
+ self._execution_model.id == saga_id,
467
492
  )
468
493
  check_result = await session.execute(check_stmt)
469
494
  if check_result.scalar_one_or_none():
@@ -492,11 +517,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
492
517
  async with self.session_factory() as session:
493
518
  try:
494
519
  await session.execute(
495
- sqlalchemy.update(SagaExecutionModel)
496
- .where(SagaExecutionModel.id == saga_id)
520
+ sqlalchemy.update(self._execution_model)
521
+ .where(self._execution_model.id == saga_id)
497
522
  .values(
498
523
  status=status,
499
- version=SagaExecutionModel.version + 1,
524
+ version=self._execution_model.version + 1,
500
525
  ),
501
526
  )
502
527
  await session.commit()
@@ -514,7 +539,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
514
539
  ) -> None:
515
540
  async with self.session_factory() as session:
516
541
  try:
517
- log_entry = SagaLogModel(
542
+ log_entry = self._log_model(
518
543
  saga_id=saga_id,
519
544
  step_name=step_name,
520
545
  action=action,
@@ -534,8 +559,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
534
559
  read_for_update: bool = False,
535
560
  ) -> tuple[SagaStatus, dict[str, typing.Any], int]:
536
561
  async with self.session_factory() as session:
537
- stmt = sqlalchemy.select(SagaExecutionModel).where(
538
- SagaExecutionModel.id == saga_id,
562
+ stmt = sqlalchemy.select(self._execution_model).where(
563
+ self._execution_model.id == saga_id,
539
564
  )
540
565
  if read_for_update:
541
566
  stmt = stmt.with_for_update()
@@ -560,9 +585,9 @@ class SqlAlchemySagaStorage(ISagaStorage):
560
585
  ) -> list[SagaLogEntry]:
561
586
  async with self.session_factory() as session:
562
587
  result = await session.execute(
563
- sqlalchemy.select(SagaLogModel)
564
- .where(SagaLogModel.saga_id == saga_id)
565
- .order_by(SagaLogModel.created_at),
588
+ sqlalchemy.select(self._log_model)
589
+ .where(self._log_model.saga_id == saga_id)
590
+ .order_by(self._log_model.created_at),
566
591
  )
567
592
  rows = result.scalars().all()
568
593
 
@@ -596,20 +621,20 @@ class SqlAlchemySagaStorage(ISagaStorage):
596
621
  )
597
622
  async with self.session_factory() as session:
598
623
  stmt = (
599
- sqlalchemy.select(SagaExecutionModel.id)
600
- .where(SagaExecutionModel.status.in_(recoverable))
601
- .where(SagaExecutionModel.recovery_attempts < max_recovery_attempts)
624
+ sqlalchemy.select(self._execution_model.id)
625
+ .where(self._execution_model.status.in_(recoverable))
626
+ .where(self._execution_model.recovery_attempts < max_recovery_attempts)
602
627
  )
603
628
  if saga_name is not None:
604
- stmt = stmt.where(SagaExecutionModel.name == saga_name)
629
+ stmt = stmt.where(self._execution_model.name == saga_name)
605
630
  if stale_after_seconds is not None:
606
631
  threshold = datetime.datetime.now(
607
632
  datetime.timezone.utc,
608
633
  ) - datetime.timedelta(
609
634
  seconds=stale_after_seconds,
610
635
  )
611
- stmt = stmt.where(SagaExecutionModel.updated_at < threshold)
612
- stmt = stmt.order_by(SagaExecutionModel.updated_at.asc()).limit(limit)
636
+ stmt = stmt.where(self._execution_model.updated_at < threshold)
637
+ stmt = stmt.order_by(self._execution_model.updated_at.asc()).limit(limit)
613
638
  result = await session.execute(stmt)
614
639
  rows = result.scalars().all()
615
640
  return [typing.cast(uuid.UUID, row) for row in rows]
@@ -633,13 +658,15 @@ class SqlAlchemySagaStorage(ISagaStorage):
633
658
  async with self.session_factory() as session:
634
659
  try:
635
660
  values: dict[str, typing.Any] = {
636
- "recovery_attempts": SagaExecutionModel.recovery_attempts + 1,
637
- "version": SagaExecutionModel.version + 1,
661
+ "recovery_attempts": self._execution_model.recovery_attempts + 1,
662
+ "version": self._execution_model.version + 1,
638
663
  }
639
664
  if new_status is not None:
640
665
  values["status"] = new_status
641
666
  result = await session.execute(
642
- sqlalchemy.update(SagaExecutionModel).where(SagaExecutionModel.id == saga_id).values(**values),
667
+ sqlalchemy.update(self._execution_model)
668
+ .where(self._execution_model.id == saga_id)
669
+ .values(**values),
643
670
  )
644
671
  if result.rowcount == 0: # type: ignore[attr-defined]
645
672
  raise ValueError(f"Saga {saga_id} not found")
@@ -656,11 +683,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
656
683
  async with self.session_factory() as session:
657
684
  try:
658
685
  result = await session.execute(
659
- sqlalchemy.update(SagaExecutionModel)
660
- .where(SagaExecutionModel.id == saga_id)
686
+ sqlalchemy.update(self._execution_model)
687
+ .where(self._execution_model.id == saga_id)
661
688
  .values(
662
689
  recovery_attempts=attempts,
663
- version=SagaExecutionModel.version + 1,
690
+ version=self._execution_model.version + 1,
664
691
  ),
665
692
  )
666
693
  if result.rowcount == 0: # type: ignore[attr-defined]
@@ -0,0 +1,203 @@
1
+ """SQLModel saga storage: same schema and operations as the SQLAlchemy storage."""
2
+
3
+ import contextlib
4
+ import datetime
5
+ import typing
6
+ import uuid
7
+
8
+ try:
9
+ from cqrs.saga.storage.sqlalchemy import (
10
+ DEFAULT_SAGA_EXECUTION_TABLE_NAME,
11
+ DEFAULT_SAGA_LOG_TABLE_NAME,
12
+ SAGA_EXECUTION_TABLE_NAME,
13
+ SAGA_LOG_TABLE_NAME,
14
+ SqlAlchemySagaStorage,
15
+ )
16
+
17
+ import sqlalchemy
18
+ from sqlalchemy import func
19
+ from sqlalchemy.ext.asyncio import async_sessionmaker
20
+ from sqlmodel import Field
21
+ from sqlmodel.ext.asyncio.session import AsyncSession
22
+
23
+ from cqrs.saga.storage.enums import SagaStatus, SagaStepStatus
24
+ from cqrs.sqlalchemy_types import JSONType
25
+ from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
26
+ except ImportError:
27
+ raise ImportError(
28
+ "You are trying to use the SQLModel saga storage implementation, "
29
+ "but 'sqlmodel' is not installed. "
30
+ "Please install it using: pip install python-cqrs[sqlmodel]",
31
+ ) from None
32
+
33
+ Base = CqrsSQLModel
34
+
35
+
36
+ class SagaExecutionModel(Base, table=True):
37
+ __tablename__ = SAGA_EXECUTION_TABLE_NAME # type: ignore[assignment]
38
+
39
+ id: uuid.UUID = Field(
40
+ sa_column=sqlalchemy.Column(
41
+ sqlalchemy.Uuid,
42
+ primary_key=True,
43
+ nullable=False,
44
+ comment="Saga ID",
45
+ ),
46
+ )
47
+ name: str = Field(
48
+ sa_column=sqlalchemy.Column(
49
+ sqlalchemy.String(255),
50
+ nullable=False,
51
+ comment="Saga Name",
52
+ ),
53
+ )
54
+ status: SagaStatus = Field(
55
+ default=SagaStatus.PENDING,
56
+ sa_column=sqlalchemy.Column(
57
+ sqlalchemy.Enum(SagaStatus),
58
+ nullable=False,
59
+ default=SagaStatus.PENDING,
60
+ comment="Current status of the saga",
61
+ ),
62
+ )
63
+ context: dict[str, typing.Any] = Field(
64
+ sa_column=sqlalchemy.Column(
65
+ JSONType,
66
+ nullable=False,
67
+ comment="Serialized context",
68
+ ),
69
+ )
70
+ version: int = Field(
71
+ default=1,
72
+ sa_column=sqlalchemy.Column(
73
+ sqlalchemy.Integer,
74
+ nullable=False,
75
+ default=1,
76
+ comment="Optimistic locking version",
77
+ ),
78
+ )
79
+ created_at: datetime.datetime | None = Field(
80
+ default=None,
81
+ sa_column=sqlalchemy.Column(
82
+ sqlalchemy.DateTime(timezone=True),
83
+ nullable=False,
84
+ server_default=func.now(),
85
+ comment="Creation timestamp",
86
+ ),
87
+ )
88
+ updated_at: datetime.datetime | None = Field(
89
+ default=None,
90
+ sa_column=sqlalchemy.Column(
91
+ sqlalchemy.DateTime(timezone=True),
92
+ nullable=False,
93
+ server_default=func.now(),
94
+ onupdate=func.now(),
95
+ comment="Last update timestamp",
96
+ ),
97
+ )
98
+ recovery_attempts: int = Field(
99
+ default=0,
100
+ sa_column=sqlalchemy.Column(
101
+ sqlalchemy.Integer,
102
+ nullable=False,
103
+ default=0,
104
+ server_default=sqlalchemy.text("0"),
105
+ comment="Number of recovery attempts",
106
+ ),
107
+ )
108
+
109
+
110
+ class SagaLogModel(Base, table=True):
111
+ __tablename__ = SAGA_LOG_TABLE_NAME # type: ignore[assignment]
112
+
113
+ id: int | None = Field(
114
+ default=None,
115
+ sa_column=sqlalchemy.Column(
116
+ sqlalchemy.BigInteger(),
117
+ sqlalchemy.Identity(),
118
+ primary_key=True,
119
+ nullable=False,
120
+ autoincrement=True,
121
+ comment="Log ID",
122
+ ),
123
+ )
124
+ saga_id: uuid.UUID = Field(
125
+ sa_column=sqlalchemy.Column(
126
+ sqlalchemy.Uuid,
127
+ sqlalchemy.ForeignKey(f"{SAGA_EXECUTION_TABLE_NAME}.id"),
128
+ nullable=False,
129
+ comment="Saga ID",
130
+ ),
131
+ )
132
+ step_name: str = Field(
133
+ sa_column=sqlalchemy.Column(
134
+ sqlalchemy.String(255),
135
+ nullable=False,
136
+ comment="Name of the step",
137
+ ),
138
+ )
139
+ action: str = Field(
140
+ sa_column=sqlalchemy.Column(
141
+ sqlalchemy.String(50),
142
+ nullable=False,
143
+ comment="Action performed (act/compensate)",
144
+ ),
145
+ )
146
+ status: SagaStepStatus = Field(
147
+ sa_column=sqlalchemy.Column(
148
+ sqlalchemy.Enum(SagaStepStatus),
149
+ nullable=False,
150
+ comment="Status of the step",
151
+ ),
152
+ )
153
+ details: str | None = Field(
154
+ default=None,
155
+ sa_column=sqlalchemy.Column(
156
+ sqlalchemy.Text,
157
+ nullable=True,
158
+ comment="Additional details or error message",
159
+ ),
160
+ )
161
+ created_at: datetime.datetime | None = Field(
162
+ default=None,
163
+ sa_column=sqlalchemy.Column(
164
+ sqlalchemy.DateTime(timezone=True),
165
+ nullable=False,
166
+ server_default=func.now(),
167
+ comment="Timestamp of the log entry",
168
+ ),
169
+ )
170
+
171
+
172
+ class SqlModelSagaStorage(SqlAlchemySagaStorage):
173
+ """Saga storage that persists state in SQLModel tables.
174
+
175
+ ``session_factory`` must be an ``async_sessionmaker`` built with
176
+ ``class_=sqlmodel.ext.asyncio.session.AsyncSession``. Every operation of
177
+ ``SqlAlchemySagaStorage`` is available, including checkpoint runs via
178
+ ``create_run``, optimistic locking, and recovery queries.
179
+ """
180
+
181
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
182
+ @contextlib.asynccontextmanager
183
+ async def _open() -> typing.AsyncGenerator[SqlAlchemyExecuteSession, None]:
184
+ async with session_factory() as session:
185
+ yield SqlAlchemyExecuteSession(session)
186
+
187
+ super().__init__(
188
+ _open, # type: ignore[arg-type]
189
+ execution_model=SagaExecutionModel,
190
+ log_model=SagaLogModel,
191
+ )
192
+
193
+
194
+ __all__ = (
195
+ "Base",
196
+ "DEFAULT_SAGA_EXECUTION_TABLE_NAME",
197
+ "DEFAULT_SAGA_LOG_TABLE_NAME",
198
+ "SAGA_EXECUTION_TABLE_NAME",
199
+ "SAGA_LOG_TABLE_NAME",
200
+ "SagaExecutionModel",
201
+ "SagaLogModel",
202
+ "SqlModelSagaStorage",
203
+ )
cqrs/sqlmodel_base.py ADDED
@@ -0,0 +1,45 @@
1
+ """Declarative base shared by the optional SQLModel outbox and saga models."""
2
+
3
+ import typing
4
+
5
+ try:
6
+ from sqlalchemy.ext.asyncio import AsyncSession as SqlAlchemyAsyncSession
7
+ from sqlalchemy.orm import registry
8
+ from sqlmodel import SQLModel
9
+ from sqlmodel.ext.asyncio.session import AsyncSession
10
+ except ImportError:
11
+ raise ImportError(
12
+ "You are trying to use the SQLModel integration, "
13
+ "but 'sqlmodel' is not installed. "
14
+ "Please install it using: pip install python-cqrs[sqlmodel]",
15
+ ) from None
16
+
17
+ cqrs_registry = registry()
18
+
19
+
20
+ class CqrsSQLModel(SQLModel, registry=cqrs_registry):
21
+ """Base for library SQLModel tables.
22
+
23
+ Tables live on this class's metadata, not on the application's
24
+ ``SQLModel.metadata``. Call ``Base.metadata.create_all`` the same way as
25
+ with the SQLAlchemy models, or rebind the outbox model onto your own base.
26
+ """
27
+
28
+
29
+ class SqlAlchemyExecuteSession:
30
+ """SQLModel session whose ``execute`` is the SQLAlchemy implementation.
31
+
32
+ ``sqlmodel.AsyncSession.execute`` is deprecated in favor of ``exec`` and
33
+ warns on every call. The outbox and saga storage use Core ``execute``
34
+ because they need ``Result.rowcount`` and ``scalars()`` unchanged from the
35
+ SQLAlchemy repositories.
36
+ """
37
+
38
+ def __init__(self, session: AsyncSession) -> None:
39
+ self._session = session
40
+
41
+ def __getattr__(self, name: str) -> typing.Any:
42
+ return getattr(self._session, name)
43
+
44
+ async def execute(self, statement: typing.Any, params: typing.Any = None, **kwargs: typing.Any) -> typing.Any:
45
+ return await SqlAlchemyAsyncSession.execute(self._session, statement, params, **kwargs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-cqrs
3
- Version: 5.0.0
3
+ Version: 5.1.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>
@@ -29,6 +29,9 @@ Provides-Extra: pydantic
29
29
  Requires-Dist: pydantic==2.*; extra == "pydantic"
30
30
  Provides-Extra: sqlalchemy
31
31
  Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "sqlalchemy"
32
+ Provides-Extra: sqlmodel
33
+ Requires-Dist: sqlmodel>=0.0.22; extra == "sqlmodel"
34
+ Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "sqlmodel"
32
35
  Provides-Extra: dev
33
36
  Requires-Dist: pycln==2.5.0; extra == "dev"
34
37
  Requires-Dist: pre-commit==3.8.0; extra == "dev"
@@ -54,6 +57,8 @@ Requires-Dist: dishka>=1.0; extra == "dev"
54
57
  Requires-Dist: protobuf>=4.25.8; extra == "dev"
55
58
  Requires-Dist: pydantic==2.*; extra == "dev"
56
59
  Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "dev"
60
+ Requires-Dist: sqlmodel>=0.0.22; extra == "dev"
61
+ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
57
62
  Provides-Extra: examples
58
63
  Requires-Dist: fastapi==0.109.*; extra == "examples"
59
64
  Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
@@ -98,9 +103,6 @@ Dynamic: license-file
98
103
  <a href="https://codecov.io/gh/vadikko2/python-cqrs">
99
104
  <img src="https://img.shields.io/codecov/c/github/vadikko2/python-cqrs?logo=codecov&logoColor=white" alt="Coverage">
100
105
  </a>
101
- <a href="https://codspeed.io/vadikko2/python-cqrs?utm_source=badge">
102
- <img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed">
103
- </a>
104
106
  <a href="https://mkdocs.python-cqrs.dev/">
105
107
  <img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
106
108
  </a>
@@ -113,17 +115,11 @@ Dynamic: license-file
113
115
  </p>
114
116
  </div>
115
117
 
116
- > [!WARNING]
117
- > **Breaking Changes in v5.0.0**
118
- >
119
- > Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
120
-
121
118
  ## Table of Contents
122
119
 
123
120
  - [Overview](#overview)
124
121
  - [When NOT to use python-cqrs](#when-not-to-use-python-cqrs)
125
122
  - [Installation](#installation)
126
- - [Version Support](#version-support)
127
123
  - [Quick Start](#quick-start)
128
124
  - [Request and Response Types](#request-and-response-types)
129
125
  - [Request Handlers](#request-handlers)
@@ -166,7 +162,7 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
166
162
 
167
163
  - **Typing:** Pydantic [v2.*](https://docs.pydantic.dev/2.8/) and `IRequest`/`IResponse` interfaces — use Pydantic-based, dataclass-based, or custom Request/Response implementations.
168
164
  - **Broker:** Kafka via [aiokafka](https://github.com/aio-libs/aiokafka).
169
- - **Storage:** SQLAlchemy-based Outbox and Saga storage on MySQL/MariaDB and PostgreSQL.
165
+ - **Storage:** SQLAlchemy and SQLModel outbox and saga storage on MySQL/MariaDB and PostgreSQL.
170
166
  - **Integration:** Ready for integration with FastAPI and FastStream.
171
167
  - **Documentation:** Built-in Mermaid diagram generation (Sequence and Class diagrams).
172
168
  - **Protobuf:** Interface-level support for converting Notification events to Protobuf and back.
@@ -189,11 +185,12 @@ Do not add a Saga for a single `UPDATE` in one service, and do not use a Saga as
189
185
  pip install python-cqrs
190
186
  ```
191
187
 
192
- Optional extras (5.0+ — pydantic and SQLAlchemy are no longer core dependencies):
188
+ Optional extras (5.0+ — pydantic and SQLAlchemy are no longer core dependencies; SQLModel storage is 5.1+):
193
189
 
194
190
  ```bash
195
191
  pip install "python-cqrs[pydantic]" # PydanticRequest / Pydantic* events
196
192
  pip install "python-cqrs[sqlalchemy]" # Outbox / Saga SQLAlchemy storage
193
+ pip install "python-cqrs[sqlmodel]" # Outbox / Saga SQLModel storage
197
194
  pip install "python-cqrs[kafka]" # Kafka broker (aiokafka)
198
195
  pip install "python-cqrs[examples]" # FastAPI, FastStream, uvicorn, etc.
199
196
  pip install "python-cqrs[aiobreaker]" # Circuit breaker for saga fallbacks
@@ -203,37 +200,6 @@ Default `Request` / `Response` / `Event` / `DomainEvent` / `NotificationEvent` a
203
200
  are dataclass-based (`DC*`). Use `Pydantic*` types with the pydantic extra when you
204
201
  need validation.
205
202
 
206
- ### Import paths (5.0+)
207
-
208
- | Area | Import |
209
- |------|--------|
210
- | Bootstrap | `from cqrs.bootstrap import requests, events, saga` |
211
- | Mediators | `from cqrs.mediators import RequestMediator, SagaMediator, …` |
212
- | Handlers | `from cqrs.handlers import RequestHandler, EventHandler, …` |
213
- | Models | `from cqrs.models import Request, Event, …` / `cqrs.models.pydantic` |
214
- | Maps | `from cqrs.mapping import RequestMap, EventMap, OutboxedEventMap` |
215
- | Mermaid | `from cqrs.mermaid import CoRMermaid, SagaMermaid` |
216
- | Outbox producer | `from cqrs.message_brokers.producer import EventProducer` |
217
-
218
- `SagaMediator.execute(...)` replaces the former `SagaMediator.stream(...)`
219
- (StreamingRequestMediator.stream is unchanged).
220
-
221
- ## Version Support
222
-
223
- | Line | Git branch | What changes |
224
- |------|------------|--------------|
225
- | **4.x** | `4.x` | Bug fixes and security only (~12 months after 5.0.0) |
226
- | **5.x** | `master` | Features and breaking changes |
227
-
228
- Stay on 4.x until you migrate:
229
-
230
- ```bash
231
- pip install "python-cqrs>=4,<5"
232
- ```
233
-
234
- Docs: [4.x](https://mkdocs.python-cqrs.dev/4.0/) and [5.x / latest](https://mkdocs.python-cqrs.dev/latest/) (version switcher after mike deploy). See [SECURITY.md](SECURITY.md) for the support window and vulnerability reporting, and [CONTRIBUTING.md](CONTRIBUTING.md#8-maintaining--fixing-4x) for how to contribute 4.x bugfixes.
235
-
236
- A package-level EOL `DeprecationWarning` toward 5.x will be added on the **first 4.x patch after 5.0.0** — not before.
237
203
 
238
204
  ## Quick Start
239
205
 
@@ -1,8 +1,9 @@
1
- cqrs/__init__.py,sha256=ay8XdNvYLDqSSpFwcUDbRBggJNTnzYncKu_6qgFakwE,5666
1
+ cqrs/__init__.py,sha256=W9uUGYuGci-4dIdmMUIjaUyJ31-ZyOU0AY0Od4gS_Gg,6170
2
2
  cqrs/_dataclass_utils.py,sha256=OqKF3Gifarcj7rjFWUe60BcBrpt0NePgiXbbiuigMdk,3555
3
3
  cqrs/circuit_breaker.py,sha256=OEq6N5YK8-uOSXEsC5IfJxPxXOw-UIgjccrlLv22Tgg,2385
4
4
  cqrs/generic_utils.py,sha256=DsUq6oCLpZVBkxMmW5cgRrbEcC_DlNTeXuwVv2LPHmI,1571
5
5
  cqrs/sqlalchemy_types.py,sha256=VHt8yzlddU2ZdLZ-c2gcS53TnmtQcb2wju8Si5xPfZE,7430
6
+ cqrs/sqlmodel_base.py,sha256=7Cujk82AhBPP_Q1p3qbINN2OxtoC7qSMAwMSZbOmAhU,1674
6
7
  cqrs/types.py,sha256=gbHLEPKZloViUJc-jZZ-HhakZvlLxbzk3FT_VTSHOgc,309
7
8
  cqrs/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
9
  cqrs/adapters/amqp.py,sha256=sKtH9XgVlKPI2-_bGc9CNxbRUi8vydUz3FuwvdSepho,2997
@@ -72,7 +73,8 @@ cqrs/models/response.py,sha256=tc4-L0lHOdsV82ftEDeglj65KODrV1Z0uyG1-49BhM4,2032
72
73
  cqrs/outbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
74
  cqrs/outbox/mock.py,sha256=9sp6OESCqWadnkSQ7tz6C_5FPlXLkVLbQWoP7lbfkz8,2212
74
75
  cqrs/outbox/repository.py,sha256=-NsefTE-Zv9fcYEigCSQXVhDwJ8nC7B7JgGkU2S9dZM,2499
75
- cqrs/outbox/sqlalchemy.py,sha256=F8tlqdVhGuJ4j5jthyoy_RgHmSMNeweXaXDiaBW30-A,10259
76
+ cqrs/outbox/sqlalchemy.py,sha256=toWW-HZBgCoKKb2DHmnLAyCEEq-VoPnVGec3ydEBxxQ,10353
77
+ cqrs/outbox/sqlmodel.py,sha256=w1tOyiR0tV7S8vHx0SQsU0j48yA00zW-ehjBrU8mT7Q,5550
76
78
  cqrs/requests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
79
  cqrs/requests/fallback.py,sha256=6hysbuxvLti0laveuMMLazIMkjj5VYBxFcY5wkXEXtI,4431
78
80
  cqrs/saga/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -89,14 +91,15 @@ cqrs/saga/storage/enums.py,sha256=yOAsPFUBA7wY-_7ft5wtJcS3wXdsAzUHQ-8X0iuMXZA,44
89
91
  cqrs/saga/storage/memory.py,sha256=x8Nuib259Z4s-JO4K1bDnUHavwwz1wYf6Oo2p2oh5R0,12836
90
92
  cqrs/saga/storage/models.py,sha256=VF-oXCajhwcRZLAhLfvhHq4HRVU7L2WQJLzVfLUxxPg,919
91
93
  cqrs/saga/storage/protocol.py,sha256=7k_z49EpABqPq9kAd3R85wPe_yx4hCjEpvWgrW6kNAQ,14031
92
- cqrs/saga/storage/sqlalchemy.py,sha256=7OENFOOSJ-BgMtFIzzqLCndhMjgKOrik5_iF_WlRpjU,24925
94
+ cqrs/saga/storage/sqlalchemy.py,sha256=uYHorxz-DcaYISLcy_toI1x129MrxYsHOyRNYR2bHzs,26084
95
+ cqrs/saga/storage/sqlmodel.py,sha256=7vdJzAmm2uwaGrlfCjUsf-1ndwoKnBGqPwPP6D3YIjA,6006
93
96
  cqrs/serializers/__init__.py,sha256=vzCKkQ-zm4Ua6EKhJ8syTxycjn8QK_Zuvz0M5BvcCqk,378
94
97
  cqrs/serializers/default.py,sha256=USVd1dwtgMgMd2FlK8BHtc7B61myzWcosHR4VkYEKB4,2617
95
98
  cqrs/serializers/json.py,sha256=HBnay7_Yzb_ap78jaewZbKYtI7eNGArg3qba-gF_PF0,628
96
99
  cqrs/serializers/protobuf.py,sha256=phqEgQrhx9jLCFQGz7xr-3QJbgJHQmBLeDCzxD9dAhE,1640
97
100
  cqrs/serializers/protocol.py,sha256=lRyzsc_p7c5ZWUnzXPptzd4rSUOOu-yR7KrYWPBVER4,588
98
- python_cqrs-5.0.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
99
- python_cqrs-5.0.0.dist-info/METADATA,sha256=2CxMECUf9A0_3mlE4BmjB1dHmpEuIVJuIZZ7qkg_1jM,51778
100
- python_cqrs-5.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
101
- python_cqrs-5.0.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
102
- python_cqrs-5.0.0.dist-info/RECORD,,
101
+ python_cqrs-5.1.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
102
+ python_cqrs-5.1.0.dist-info/METADATA,sha256=2rNVYMawK8Er2m08BQRen_41j408yK511R1s_2CFcwM,50165
103
+ python_cqrs-5.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
104
+ python_cqrs-5.1.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
105
+ python_cqrs-5.1.0.dist-info/RECORD,,