python-cqrs 4.3.1__tar.gz → 4.4.2__tar.gz

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.
Files changed (86) hide show
  1. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/PKG-INFO +181 -19
  2. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/README.md +180 -18
  3. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/pyproject.toml +1 -1
  4. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/__init__.py +13 -7
  5. python_cqrs-4.4.2/src/cqrs/deserializers/__init__.py +13 -0
  6. python_cqrs-4.4.2/src/cqrs/deserializers/exceptions.py +15 -0
  7. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/deserializers/json.py +2 -6
  8. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/deserializers/protobuf.py +2 -6
  9. python_cqrs-4.4.2/src/cqrs/dispatcher/__init__.py +11 -0
  10. python_cqrs-4.4.2/src/cqrs/dispatcher/event.py +42 -0
  11. python_cqrs-4.4.2/src/cqrs/dispatcher/exceptions.py +6 -0
  12. python_cqrs-4.4.2/src/cqrs/dispatcher/models.py +16 -0
  13. python_cqrs-4.4.2/src/cqrs/dispatcher/request.py +81 -0
  14. python_cqrs-4.4.2/src/cqrs/dispatcher/streaming.py +85 -0
  15. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/event.py +8 -7
  16. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/event_emitter.py +8 -11
  17. python_cqrs-4.4.2/src/cqrs/events/event_handler.py +26 -0
  18. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/map.py +6 -10
  19. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/mediator.py +53 -49
  20. python_cqrs-4.4.2/src/cqrs/middlewares/base.py +49 -0
  21. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/middlewares/logging.py +4 -7
  22. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/map.py +4 -4
  23. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/mock.py +1 -1
  24. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/repository.py +3 -5
  25. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/sqlalchemy.py +1 -3
  26. python_cqrs-4.4.2/src/cqrs/requests/__init__.py +0 -0
  27. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/requests/bootstrap.py +29 -28
  28. python_cqrs-4.4.2/src/cqrs/requests/cor_request_handler.py +80 -0
  29. python_cqrs-4.4.2/src/cqrs/requests/map.py +32 -0
  30. python_cqrs-4.4.2/src/cqrs/requests/mermaid.py +307 -0
  31. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/requests/request.py +3 -0
  32. python_cqrs-4.4.2/src/cqrs/requests/request_handler.py +93 -0
  33. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/response.py +0 -1
  34. python_cqrs-4.4.2/src/cqrs/saga/__init__.py +0 -0
  35. python_cqrs-4.4.2/src/cqrs/saga/mermaid.py +287 -0
  36. python_cqrs-4.4.2/src/cqrs/saga/models.py +78 -0
  37. python_cqrs-4.4.2/src/cqrs/saga/recovery.py +111 -0
  38. python_cqrs-4.4.2/src/cqrs/saga/saga.py +577 -0
  39. python_cqrs-4.4.2/src/cqrs/saga/step.py +179 -0
  40. python_cqrs-4.4.2/src/cqrs/saga/storage/__init__.py +10 -0
  41. python_cqrs-4.4.2/src/cqrs/saga/storage/enums.py +22 -0
  42. python_cqrs-4.4.2/src/cqrs/saga/storage/memory.py +106 -0
  43. python_cqrs-4.4.2/src/cqrs/saga/storage/models.py +35 -0
  44. python_cqrs-4.4.2/src/cqrs/saga/storage/protocol.py +60 -0
  45. python_cqrs-4.4.2/src/cqrs/saga/storage/sqlalchemy.py +213 -0
  46. python_cqrs-4.4.2/src/cqrs/types.py +18 -0
  47. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/PKG-INFO +181 -19
  48. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/SOURCES.txt +20 -1
  49. python_cqrs-4.3.1/src/cqrs/deserializers/__init__.py +0 -12
  50. python_cqrs-4.3.1/src/cqrs/dispatcher/__init__.py +0 -13
  51. python_cqrs-4.3.1/src/cqrs/dispatcher/dispatcher.py +0 -205
  52. python_cqrs-4.3.1/src/cqrs/events/event_handler.py +0 -46
  53. python_cqrs-4.3.1/src/cqrs/middlewares/base.py +0 -30
  54. python_cqrs-4.3.1/src/cqrs/requests/__init__.py +0 -17
  55. python_cqrs-4.3.1/src/cqrs/requests/cor_request_handler.py +0 -124
  56. python_cqrs-4.3.1/src/cqrs/requests/map.py +0 -30
  57. python_cqrs-4.3.1/src/cqrs/requests/request_handler.py +0 -184
  58. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/LICENSE +0 -0
  59. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/setup.cfg +0 -0
  60. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/__init__.py +0 -0
  61. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/amqp.py +0 -0
  62. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/kafka.py +0 -0
  63. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/protocol.py +0 -0
  64. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/__init__.py +0 -0
  65. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/protocol.py +0 -0
  66. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/zlib.py +0 -0
  67. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/__init__.py +0 -0
  68. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/dependency_injector.py +0 -0
  69. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/di.py +0 -0
  70. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/protocol.py +0 -0
  71. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/__init__.py +0 -0
  72. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/bootstrap.py +0 -0
  73. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/__init__.py +0 -0
  74. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/amqp.py +0 -0
  75. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/devnull.py +0 -0
  76. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/kafka.py +0 -0
  77. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/protocol.py +0 -0
  78. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/middlewares/__init__.py +0 -0
  79. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/__init__.py +0 -0
  80. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/producer.py +0 -0
  81. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/__init__.py +0 -0
  82. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/default.py +0 -0
  83. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/protobuf.py +0 -0
  84. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  85. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/requires.txt +0 -0
  86. {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-cqrs
3
- Version: 4.3.1
3
+ Version: 4.4.2
4
4
  Summary: Python CQRS pattern implementation
5
5
  Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
6
6
  Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
@@ -49,7 +49,33 @@ Provides-Extra: rabbit
49
49
  Requires-Dist: aio-pika==9.3.0; extra == "rabbit"
50
50
  Dynamic: license-file
51
51
 
52
- # Python CQRS pattern implementation with Transaction Outbox supporting
52
+ <div align="center">
53
+ <h1>Python CQRS</h1>
54
+ <p>Event-Driven Architecture Framework for Distributed Systems</p>
55
+
56
+ <p>
57
+ <a href="https://pypi.org/project/python-cqrs/">
58
+ <img src="https://img.shields.io/pypi/v/python-cqrs?label=pypi&logo=pypi" alt="PyPI version">
59
+ </a>
60
+ <a href="https://pypi.org/project/python-cqrs/">
61
+ <img src="https://img.shields.io/pypi/dm/python-cqrs?label=downloads&logo=pypi" alt="PyPI downloads">
62
+ </a>
63
+ <a href="https://vadikko2.github.io/python-cqrs-mkdocs/">
64
+ <img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
65
+ </a>
66
+ <a href="https://deepwiki.com/vadikko2/python-cqrs">
67
+ <img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki">
68
+ </a>
69
+ </p>
70
+ </div>
71
+
72
+ <div align="center">
73
+ <img
74
+ src="https://raw.githubusercontent.com/vadikko2/python-cqrs-mkdocs/master/docs/img.png"
75
+ alt="Python CQRS"
76
+ style="max-width: 80%; width: 800px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 102, 204, 0.2); display: block; margin: 2rem auto;"
77
+ >
78
+ </div>
53
79
 
54
80
  ## Overview
55
81
 
@@ -72,7 +98,9 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
72
98
  9. [Protobuf](https://protobuf.dev/) events supporting;
73
99
  10. `StreamingRequestMediator` and `StreamingRequestHandler` for handling streaming requests with real-time progress updates;
74
100
  11. Parallel event processing with configurable concurrency limits;
75
- 12. Chain of Responsibility pattern support with `CORRequestHandler` for processing requests through multiple handlers in sequence.
101
+ 12. Chain of Responsibility pattern support with `CORRequestHandler` for processing requests through multiple handlers in sequence;
102
+ 13. Choreographic Saga pattern support for managing distributed transactions with automatic compensation and recovery mechanisms;
103
+ 14. Built-in Mermaid diagram generation, enabling automatic generation of Sequence and Class diagrams for documentation and visualization.
76
104
 
77
105
  ## Request Handlers
78
106
 
@@ -87,7 +115,7 @@ As a result of executing the command, an event may be produced to the broker.
87
115
  > By default, the command handler does not return any result, but it is not mandatory.
88
116
 
89
117
  ```python
90
- from cqrs.requests.request_handler import RequestHandler, SyncRequestHandler
118
+ from cqrs.requests.request_handler import RequestHandler
91
119
  from cqrs.events.event import Event
92
120
 
93
121
  class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
@@ -102,21 +130,6 @@ class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
102
130
 
103
131
  async def handle(self, request: JoinMeetingCommand) -> None:
104
132
  await self._meetings_api.join_user(request.user_id, request.meeting_id)
105
-
106
-
107
- class SyncJoinMeetingCommandHandler(SyncRequestHandler[JoinMeetingCommand, None]):
108
-
109
- def __init__(self, meetings_api: MeetingAPIProtocol) -> None:
110
- self._meetings_api = meetings_api
111
- self.events: list[Event] = []
112
-
113
- @property
114
- def events(self) -> typing.List[events.Event]:
115
- return self._events
116
-
117
- def handle(self, request: JoinMeetingCommand) -> None:
118
- # do some sync logic
119
- ...
120
133
  ```
121
134
 
122
135
  A complete example can be found in
@@ -251,6 +264,155 @@ def payment_mapper(mapper: cqrs.RequestMap) -> None:
251
264
  A complete example can be found in
252
265
  the [documentation](https://github.com/vadikko2/cqrs/blob/master/examples/cor_request_handler.py)
253
266
 
267
+ #### Mermaid Diagram Generation
268
+
269
+ The package includes built-in support for generating Mermaid diagrams from Chain of Responsibility handler chains.
270
+
271
+ ```python
272
+ from cqrs.requests.mermaid import CoRMermaid
273
+
274
+ # Create Mermaid generator from handler chain
275
+ handlers = [CreditCardHandler, PayPalHandler, DefaultHandler]
276
+ generator = CoRMermaid(handlers)
277
+
278
+ # Generate Sequence diagram showing execution flow
279
+ sequence_diagram = generator.sequence()
280
+
281
+ # Generate Class diagram showing type structure
282
+ class_diagram = generator.class_diagram()
283
+ ```
284
+
285
+ Complete example: [CoR Mermaid Diagrams](https://github.com/vadikko2/cqrs/blob/master/examples/cor_mermaid.py)
286
+
287
+ ## Saga Pattern
288
+
289
+ The package implements the Choreographic Saga pattern for managing distributed transactions across multiple services or operations.
290
+ Sagas enable eventual consistency by executing a series of steps where each step can be compensated if a subsequent step fails.
291
+
292
+ ### Key Features
293
+
294
+ - **SagaStorage**: Persists saga state and execution history, enabling recovery of interrupted sagas
295
+ - **SagaLog**: Tracks all step executions (act/compensate) with status and timestamps
296
+ - **Recovery Mechanism**: Automatically recovers interrupted sagas from storage, ensuring eventual consistency
297
+ - **Automatic Compensation**: If any step fails, all previously completed steps are automatically compensated in reverse order
298
+ - **Mermaid Diagram Generation**: Generate Sequence and Class diagrams for documentation and visualization
299
+
300
+ ### Example
301
+
302
+ ```python
303
+ from cqrs.saga.saga import Saga
304
+ from cqrs.saga.step import SagaStepHandler, SagaStepResult
305
+ from cqrs.saga.storage.memory import MemorySagaStorage
306
+ from cqrs.saga.models import SagaContext
307
+ from cqrs.response import Response
308
+ import uuid
309
+
310
+ class OrderContext(SagaContext):
311
+ order_id: str
312
+ user_id: str
313
+ items: list[str]
314
+ total_amount: float
315
+ inventory_reservation_id: str | None = None
316
+ payment_id: str | None = None
317
+
318
+ class ReserveInventoryResponse(Response):
319
+ reservation_id: str
320
+
321
+ class ProcessPaymentResponse(Response):
322
+ payment_id: str
323
+
324
+ class ReserveInventoryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
325
+ def __init__(self, inventory_service):
326
+ self._inventory_service = inventory_service
327
+
328
+ async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
329
+ # Reserve inventory
330
+ reservation_id = await self._inventory_service.reserve_items(context.order_id, context.items)
331
+ context.inventory_reservation_id = reservation_id
332
+ return self._generate_step_result(ReserveInventoryResponse(reservation_id=reservation_id))
333
+
334
+ async def compensate(self, context: OrderContext) -> None:
335
+ # Release inventory if saga fails
336
+ if context.inventory_reservation_id:
337
+ await self._inventory_service.release_items(context.inventory_reservation_id)
338
+
339
+ class ProcessPaymentStep(SagaStepHandler[OrderContext, ProcessPaymentResponse]):
340
+ def __init__(self, payment_service):
341
+ self._payment_service = payment_service
342
+
343
+ async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ProcessPaymentResponse]:
344
+ # Process payment
345
+ payment_id = await self._payment_service.charge(context.order_id, context.total_amount)
346
+ context.payment_id = payment_id
347
+ return self._generate_step_result(ProcessPaymentResponse(payment_id=payment_id))
348
+
349
+ async def compensate(self, context: OrderContext) -> None:
350
+ # Refund payment if saga fails
351
+ if context.payment_id:
352
+ await self._payment_service.refund(context.payment_id)
353
+
354
+ # Create saga with storage
355
+ storage = MemorySagaStorage()
356
+ saga = Saga(
357
+ steps=[ReserveInventoryStep, ProcessPaymentStep],
358
+ container=container, # DI container
359
+ storage=storage,
360
+ )
361
+
362
+ # Execute saga
363
+ saga_id = uuid.uuid4()
364
+ context = OrderContext(order_id="123", user_id="user_1", items=["item_1"], total_amount=100.0)
365
+
366
+ async with saga.transaction(context=context, saga_id=saga_id) as transaction:
367
+ async for step_result in transaction:
368
+ print(f"Step completed: {step_result.step_type.__name__}")
369
+ # If any step fails, compensation happens automatically
370
+ ```
371
+
372
+ The saga state and step history are persisted to `SagaStorage`. The `SagaLog` maintains a complete audit trail
373
+ of all step executions (both `act` and `compensate` operations) with timestamps and status information.
374
+ This enables the recovery mechanism to restore saga state and ensure eventual consistency even after system failures.
375
+
376
+ If a saga is interrupted (e.g., due to a crash), you can recover it using the recovery mechanism:
377
+
378
+ ```python
379
+ from cqrs.saga.recovery import recover_saga
380
+
381
+ # Recover interrupted saga - will resume from last completed step
382
+ # or continue compensation if saga was in compensating state
383
+ await recover_saga(saga, saga_id, OrderContext)
384
+
385
+ # Access execution history (SagaLog) for monitoring and debugging
386
+ history = await storage.get_step_history(saga_id)
387
+ for entry in history:
388
+ print(f"{entry.timestamp}: {entry.step_name} - {entry.action} - {entry.status}")
389
+ ```
390
+
391
+ The recovery mechanism ensures eventual consistency by:
392
+ - Loading the last known saga state from `SagaStorage`
393
+ - Checking the `SagaLog` to determine which steps were completed
394
+ - Resuming execution from the last completed step, or continuing compensation if the saga was in a compensating state
395
+ - Preventing duplicate execution of already completed steps
396
+
397
+ #### Mermaid Diagram Generation
398
+
399
+ The package includes built-in support for generating Mermaid diagrams from Saga instances.
400
+
401
+ ```python
402
+ from cqrs.saga.mermaid import SagaMermaid
403
+
404
+ # Create Mermaid generator from saga
405
+ generator = SagaMermaid(saga)
406
+
407
+ # Generate Sequence diagram showing execution flow
408
+ sequence_diagram = generator.sequence()
409
+
410
+ # Generate Class diagram showing type structure
411
+ class_diagram = generator.class_diagram()
412
+ ```
413
+
414
+ Complete example: [Saga Mermaid Diagrams](https://github.com/vadikko2/cqrs/blob/master/examples/saga_mermaid.py)
415
+
254
416
  ## Event Handlers
255
417
 
256
418
  Event handlers are designed to process `Notification` and `ECST` events that are consumed from the broker.
@@ -1,4 +1,30 @@
1
- # Python CQRS pattern implementation with Transaction Outbox supporting
1
+ <div align="center">
2
+ <h1>Python CQRS</h1>
3
+ <p>Event-Driven Architecture Framework for Distributed Systems</p>
4
+
5
+ <p>
6
+ <a href="https://pypi.org/project/python-cqrs/">
7
+ <img src="https://img.shields.io/pypi/v/python-cqrs?label=pypi&logo=pypi" alt="PyPI version">
8
+ </a>
9
+ <a href="https://pypi.org/project/python-cqrs/">
10
+ <img src="https://img.shields.io/pypi/dm/python-cqrs?label=downloads&logo=pypi" alt="PyPI downloads">
11
+ </a>
12
+ <a href="https://vadikko2.github.io/python-cqrs-mkdocs/">
13
+ <img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
14
+ </a>
15
+ <a href="https://deepwiki.com/vadikko2/python-cqrs">
16
+ <img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki">
17
+ </a>
18
+ </p>
19
+ </div>
20
+
21
+ <div align="center">
22
+ <img
23
+ src="https://raw.githubusercontent.com/vadikko2/python-cqrs-mkdocs/master/docs/img.png"
24
+ alt="Python CQRS"
25
+ style="max-width: 80%; width: 800px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 102, 204, 0.2); display: block; margin: 2rem auto;"
26
+ >
27
+ </div>
2
28
 
3
29
  ## Overview
4
30
 
@@ -21,7 +47,9 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
21
47
  9. [Protobuf](https://protobuf.dev/) events supporting;
22
48
  10. `StreamingRequestMediator` and `StreamingRequestHandler` for handling streaming requests with real-time progress updates;
23
49
  11. Parallel event processing with configurable concurrency limits;
24
- 12. Chain of Responsibility pattern support with `CORRequestHandler` for processing requests through multiple handlers in sequence.
50
+ 12. Chain of Responsibility pattern support with `CORRequestHandler` for processing requests through multiple handlers in sequence;
51
+ 13. Choreographic Saga pattern support for managing distributed transactions with automatic compensation and recovery mechanisms;
52
+ 14. Built-in Mermaid diagram generation, enabling automatic generation of Sequence and Class diagrams for documentation and visualization.
25
53
 
26
54
  ## Request Handlers
27
55
 
@@ -36,7 +64,7 @@ As a result of executing the command, an event may be produced to the broker.
36
64
  > By default, the command handler does not return any result, but it is not mandatory.
37
65
 
38
66
  ```python
39
- from cqrs.requests.request_handler import RequestHandler, SyncRequestHandler
67
+ from cqrs.requests.request_handler import RequestHandler
40
68
  from cqrs.events.event import Event
41
69
 
42
70
  class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
@@ -51,21 +79,6 @@ class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]):
51
79
 
52
80
  async def handle(self, request: JoinMeetingCommand) -> None:
53
81
  await self._meetings_api.join_user(request.user_id, request.meeting_id)
54
-
55
-
56
- class SyncJoinMeetingCommandHandler(SyncRequestHandler[JoinMeetingCommand, None]):
57
-
58
- def __init__(self, meetings_api: MeetingAPIProtocol) -> None:
59
- self._meetings_api = meetings_api
60
- self.events: list[Event] = []
61
-
62
- @property
63
- def events(self) -> typing.List[events.Event]:
64
- return self._events
65
-
66
- def handle(self, request: JoinMeetingCommand) -> None:
67
- # do some sync logic
68
- ...
69
82
  ```
70
83
 
71
84
  A complete example can be found in
@@ -200,6 +213,155 @@ def payment_mapper(mapper: cqrs.RequestMap) -> None:
200
213
  A complete example can be found in
201
214
  the [documentation](https://github.com/vadikko2/cqrs/blob/master/examples/cor_request_handler.py)
202
215
 
216
+ #### Mermaid Diagram Generation
217
+
218
+ The package includes built-in support for generating Mermaid diagrams from Chain of Responsibility handler chains.
219
+
220
+ ```python
221
+ from cqrs.requests.mermaid import CoRMermaid
222
+
223
+ # Create Mermaid generator from handler chain
224
+ handlers = [CreditCardHandler, PayPalHandler, DefaultHandler]
225
+ generator = CoRMermaid(handlers)
226
+
227
+ # Generate Sequence diagram showing execution flow
228
+ sequence_diagram = generator.sequence()
229
+
230
+ # Generate Class diagram showing type structure
231
+ class_diagram = generator.class_diagram()
232
+ ```
233
+
234
+ Complete example: [CoR Mermaid Diagrams](https://github.com/vadikko2/cqrs/blob/master/examples/cor_mermaid.py)
235
+
236
+ ## Saga Pattern
237
+
238
+ The package implements the Choreographic Saga pattern for managing distributed transactions across multiple services or operations.
239
+ Sagas enable eventual consistency by executing a series of steps where each step can be compensated if a subsequent step fails.
240
+
241
+ ### Key Features
242
+
243
+ - **SagaStorage**: Persists saga state and execution history, enabling recovery of interrupted sagas
244
+ - **SagaLog**: Tracks all step executions (act/compensate) with status and timestamps
245
+ - **Recovery Mechanism**: Automatically recovers interrupted sagas from storage, ensuring eventual consistency
246
+ - **Automatic Compensation**: If any step fails, all previously completed steps are automatically compensated in reverse order
247
+ - **Mermaid Diagram Generation**: Generate Sequence and Class diagrams for documentation and visualization
248
+
249
+ ### Example
250
+
251
+ ```python
252
+ from cqrs.saga.saga import Saga
253
+ from cqrs.saga.step import SagaStepHandler, SagaStepResult
254
+ from cqrs.saga.storage.memory import MemorySagaStorage
255
+ from cqrs.saga.models import SagaContext
256
+ from cqrs.response import Response
257
+ import uuid
258
+
259
+ class OrderContext(SagaContext):
260
+ order_id: str
261
+ user_id: str
262
+ items: list[str]
263
+ total_amount: float
264
+ inventory_reservation_id: str | None = None
265
+ payment_id: str | None = None
266
+
267
+ class ReserveInventoryResponse(Response):
268
+ reservation_id: str
269
+
270
+ class ProcessPaymentResponse(Response):
271
+ payment_id: str
272
+
273
+ class ReserveInventoryStep(SagaStepHandler[OrderContext, ReserveInventoryResponse]):
274
+ def __init__(self, inventory_service):
275
+ self._inventory_service = inventory_service
276
+
277
+ async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ReserveInventoryResponse]:
278
+ # Reserve inventory
279
+ reservation_id = await self._inventory_service.reserve_items(context.order_id, context.items)
280
+ context.inventory_reservation_id = reservation_id
281
+ return self._generate_step_result(ReserveInventoryResponse(reservation_id=reservation_id))
282
+
283
+ async def compensate(self, context: OrderContext) -> None:
284
+ # Release inventory if saga fails
285
+ if context.inventory_reservation_id:
286
+ await self._inventory_service.release_items(context.inventory_reservation_id)
287
+
288
+ class ProcessPaymentStep(SagaStepHandler[OrderContext, ProcessPaymentResponse]):
289
+ def __init__(self, payment_service):
290
+ self._payment_service = payment_service
291
+
292
+ async def act(self, context: OrderContext) -> SagaStepResult[OrderContext, ProcessPaymentResponse]:
293
+ # Process payment
294
+ payment_id = await self._payment_service.charge(context.order_id, context.total_amount)
295
+ context.payment_id = payment_id
296
+ return self._generate_step_result(ProcessPaymentResponse(payment_id=payment_id))
297
+
298
+ async def compensate(self, context: OrderContext) -> None:
299
+ # Refund payment if saga fails
300
+ if context.payment_id:
301
+ await self._payment_service.refund(context.payment_id)
302
+
303
+ # Create saga with storage
304
+ storage = MemorySagaStorage()
305
+ saga = Saga(
306
+ steps=[ReserveInventoryStep, ProcessPaymentStep],
307
+ container=container, # DI container
308
+ storage=storage,
309
+ )
310
+
311
+ # Execute saga
312
+ saga_id = uuid.uuid4()
313
+ context = OrderContext(order_id="123", user_id="user_1", items=["item_1"], total_amount=100.0)
314
+
315
+ async with saga.transaction(context=context, saga_id=saga_id) as transaction:
316
+ async for step_result in transaction:
317
+ print(f"Step completed: {step_result.step_type.__name__}")
318
+ # If any step fails, compensation happens automatically
319
+ ```
320
+
321
+ The saga state and step history are persisted to `SagaStorage`. The `SagaLog` maintains a complete audit trail
322
+ of all step executions (both `act` and `compensate` operations) with timestamps and status information.
323
+ This enables the recovery mechanism to restore saga state and ensure eventual consistency even after system failures.
324
+
325
+ If a saga is interrupted (e.g., due to a crash), you can recover it using the recovery mechanism:
326
+
327
+ ```python
328
+ from cqrs.saga.recovery import recover_saga
329
+
330
+ # Recover interrupted saga - will resume from last completed step
331
+ # or continue compensation if saga was in compensating state
332
+ await recover_saga(saga, saga_id, OrderContext)
333
+
334
+ # Access execution history (SagaLog) for monitoring and debugging
335
+ history = await storage.get_step_history(saga_id)
336
+ for entry in history:
337
+ print(f"{entry.timestamp}: {entry.step_name} - {entry.action} - {entry.status}")
338
+ ```
339
+
340
+ The recovery mechanism ensures eventual consistency by:
341
+ - Loading the last known saga state from `SagaStorage`
342
+ - Checking the `SagaLog` to determine which steps were completed
343
+ - Resuming execution from the last completed step, or continuing compensation if the saga was in a compensating state
344
+ - Preventing duplicate execution of already completed steps
345
+
346
+ #### Mermaid Diagram Generation
347
+
348
+ The package includes built-in support for generating Mermaid diagrams from Saga instances.
349
+
350
+ ```python
351
+ from cqrs.saga.mermaid import SagaMermaid
352
+
353
+ # Create Mermaid generator from saga
354
+ generator = SagaMermaid(saga)
355
+
356
+ # Generate Sequence diagram showing execution flow
357
+ sequence_diagram = generator.sequence()
358
+
359
+ # Generate Class diagram showing type structure
360
+ class_diagram = generator.class_diagram()
361
+ ```
362
+
363
+ Complete example: [Saga Mermaid Diagrams](https://github.com/vadikko2/cqrs/blob/master/examples/saga_mermaid.py)
364
+
203
365
  ## Event Handlers
204
366
 
205
367
  Event handlers are designed to process `Notification` and `ECST` events that are consumed from the broker.
@@ -29,7 +29,7 @@ maintainers = [{name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru"}]
29
29
  name = "python-cqrs"
30
30
  readme = "README.md"
31
31
  requires-python = ">=3.10"
32
- version = "4.3.1"
32
+ version = "4.4.2"
33
33
 
34
34
  [project.optional-dependencies]
35
35
  dev = [
@@ -4,7 +4,7 @@ from cqrs.container.protocol import Container
4
4
  from cqrs.events import EventMap
5
5
  from cqrs.events.event import DomainEvent, Event, NotificationEvent
6
6
  from cqrs.events.event_emitter import EventEmitter
7
- from cqrs.events.event_handler import EventHandler, SyncEventHandler
7
+ from cqrs.events.event_handler import EventHandler
8
8
  from cqrs.mediator import (
9
9
  EventMediator,
10
10
  RequestMediator,
@@ -17,15 +17,18 @@ from cqrs.outbox.sqlalchemy import (
17
17
  SqlAlchemyOutboxedEventRepository,
18
18
  )
19
19
  from cqrs.producer import EventProducer
20
- from cqrs.requests import RequestMap
20
+ from cqrs.requests.map import RequestMap
21
21
  from cqrs.requests.request import Request
22
22
  from cqrs.requests.request_handler import (
23
23
  RequestHandler,
24
24
  StreamingRequestHandler,
25
- SyncRequestHandler,
26
- SyncStreamingRequestHandler,
27
25
  )
28
26
  from cqrs.response import Response
27
+ from cqrs.requests.mermaid import CoRMermaid
28
+ from cqrs.saga.mermaid import SagaMermaid
29
+ from cqrs.saga.models import ContextT, SagaResult
30
+ from cqrs.saga.saga import Saga
31
+ from cqrs.saga.step import SagaStepHandler
29
32
 
30
33
  __all__ = (
31
34
  "RequestMediator",
@@ -38,13 +41,10 @@ __all__ = (
38
41
  "EventHandler",
39
42
  "EventMap",
40
43
  "OutboxedEventMap",
41
- "SyncEventHandler",
42
44
  "Request",
43
45
  "RequestHandler",
44
46
  "StreamingRequestHandler",
45
47
  "RequestMap",
46
- "SyncRequestHandler",
47
- "SyncStreamingRequestHandler",
48
48
  "Response",
49
49
  "OutboxedEventRepository",
50
50
  "SqlAlchemyOutboxedEventRepository",
@@ -54,4 +54,10 @@ __all__ = (
54
54
  "Compressor",
55
55
  "ZlibCompressor",
56
56
  "rebind_outbox_model",
57
+ "Saga",
58
+ "SagaStepHandler",
59
+ "SagaResult",
60
+ "ContextT",
61
+ "SagaMermaid",
62
+ "CoRMermaid",
57
63
  )
@@ -0,0 +1,13 @@
1
+ from cqrs.deserializers.exceptions import (
2
+ DeserializeJsonError,
3
+ DeserializeProtobufError,
4
+ )
5
+ from cqrs.deserializers.json import JsonDeserializer
6
+ from cqrs.deserializers.protobuf import ProtobufValueDeserializer
7
+
8
+ __all__ = (
9
+ "JsonDeserializer",
10
+ "DeserializeJsonError",
11
+ "ProtobufValueDeserializer",
12
+ "DeserializeProtobufError",
13
+ )
@@ -0,0 +1,15 @@
1
+ import typing
2
+
3
+ import pydantic
4
+
5
+
6
+ class DeserializeJsonError(pydantic.BaseModel):
7
+ error_message: str
8
+ error_type: typing.Type[Exception]
9
+ message_data: str | bytes | None
10
+
11
+
12
+ class DeserializeProtobufError(pydantic.BaseModel):
13
+ error_message: str
14
+ error_type: typing.Type[Exception]
15
+ message_data: bytes
@@ -3,17 +3,13 @@ import typing
3
3
 
4
4
  import pydantic
5
5
 
6
+ from cqrs.deserializers.exceptions import DeserializeJsonError
7
+
6
8
  _T = typing.TypeVar("_T", bound=pydantic.BaseModel)
7
9
 
8
10
  logger = logging.getLogger("cqrs")
9
11
 
10
12
 
11
- class DeserializeJsonError(pydantic.BaseModel):
12
- error_message: str
13
- error_type: typing.Type[Exception]
14
- message_data: str | bytes | None
15
-
16
-
17
13
  class JsonDeserializer(typing.Generic[_T]):
18
14
  def __init__(self, model: typing.Type[_T]):
19
15
  self._model: typing.Type[_T] = model
@@ -6,13 +6,9 @@ import pydantic
6
6
  from confluent_kafka.schema_registry import protobuf
7
7
  from google.protobuf.message import Message
8
8
 
9
- logger = logging.getLogger("cqrs")
10
-
9
+ from cqrs.deserializers.exceptions import DeserializeProtobufError
11
10
 
12
- class DeserializeProtobufError(pydantic.BaseModel):
13
- error_message: str
14
- error_type: typing.Type[Exception]
15
- message_data: bytes
11
+ logger = logging.getLogger("cqrs")
16
12
 
17
13
 
18
14
  class ProtobufValueDeserializer:
@@ -0,0 +1,11 @@
1
+ from cqrs.dispatcher.event import EventDispatcher
2
+ from cqrs.dispatcher.models import RequestDispatchResult
3
+ from cqrs.dispatcher.request import RequestDispatcher
4
+ from cqrs.dispatcher.streaming import StreamingRequestDispatcher
5
+
6
+ __all__ = (
7
+ "RequestDispatchResult",
8
+ "RequestDispatcher",
9
+ "StreamingRequestDispatcher",
10
+ "EventDispatcher",
11
+ )
@@ -0,0 +1,42 @@
1
+ import logging
2
+ import typing
3
+
4
+ from cqrs.container.protocol import Container
5
+ from cqrs.events.event import Event
6
+ from cqrs.events.event_handler import EventHandler
7
+ from cqrs.events.map import EventMap
8
+ from cqrs.middlewares.base import MiddlewareChain
9
+
10
+ _EventHandler: typing.TypeAlias = EventHandler
11
+
12
+ logger = logging.getLogger("cqrs")
13
+
14
+
15
+ class EventDispatcher:
16
+ def __init__(
17
+ self,
18
+ event_map: EventMap,
19
+ container: Container,
20
+ middleware_chain: MiddlewareChain | None = None,
21
+ ) -> None:
22
+ self._event_map = event_map
23
+ self._container = container
24
+ self._middleware_chain = middleware_chain or MiddlewareChain()
25
+
26
+ async def _handle_event(
27
+ self,
28
+ event: Event,
29
+ handle_type: typing.Type[_EventHandler],
30
+ ):
31
+ handler: _EventHandler = await self._container.resolve(handle_type)
32
+ await handler.handle(event)
33
+
34
+ async def dispatch(self, event: Event) -> None:
35
+ handler_types = self._event_map.get(type(event), [])
36
+ if not handler_types:
37
+ logger.warning(
38
+ "Handlers for event %s not found",
39
+ type(event).__name__,
40
+ )
41
+ for h_type in handler_types:
42
+ await self._handle_event(event, h_type)
@@ -0,0 +1,6 @@
1
+ class RequestHandlerDoesNotExist(Exception):
2
+ pass
3
+
4
+
5
+ class RequestHandlerTypeError(Exception):
6
+ pass
@@ -0,0 +1,16 @@
1
+ import logging
2
+ import typing
3
+
4
+ import pydantic
5
+
6
+ from cqrs.events.event import Event
7
+ from cqrs.response import Response
8
+
9
+ logger = logging.getLogger("cqrs")
10
+
11
+ _ResponseT = typing.TypeVar("_ResponseT", Response, None, covariant=True)
12
+
13
+
14
+ class RequestDispatchResult(pydantic.BaseModel, typing.Generic[_ResponseT]):
15
+ response: _ResponseT = pydantic.Field(default=None)
16
+ events: typing.List[Event] = pydantic.Field(default_factory=list)