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.
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/PKG-INFO +181 -19
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/README.md +180 -18
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/pyproject.toml +1 -1
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/__init__.py +13 -7
- python_cqrs-4.4.2/src/cqrs/deserializers/__init__.py +13 -0
- python_cqrs-4.4.2/src/cqrs/deserializers/exceptions.py +15 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/deserializers/json.py +2 -6
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/deserializers/protobuf.py +2 -6
- python_cqrs-4.4.2/src/cqrs/dispatcher/__init__.py +11 -0
- python_cqrs-4.4.2/src/cqrs/dispatcher/event.py +42 -0
- python_cqrs-4.4.2/src/cqrs/dispatcher/exceptions.py +6 -0
- python_cqrs-4.4.2/src/cqrs/dispatcher/models.py +16 -0
- python_cqrs-4.4.2/src/cqrs/dispatcher/request.py +81 -0
- python_cqrs-4.4.2/src/cqrs/dispatcher/streaming.py +85 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/event.py +8 -7
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/event_emitter.py +8 -11
- python_cqrs-4.4.2/src/cqrs/events/event_handler.py +26 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/map.py +6 -10
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/mediator.py +53 -49
- python_cqrs-4.4.2/src/cqrs/middlewares/base.py +49 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/middlewares/logging.py +4 -7
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/map.py +4 -4
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/mock.py +1 -1
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/repository.py +3 -5
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/sqlalchemy.py +1 -3
- python_cqrs-4.4.2/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/requests/bootstrap.py +29 -28
- python_cqrs-4.4.2/src/cqrs/requests/cor_request_handler.py +80 -0
- python_cqrs-4.4.2/src/cqrs/requests/map.py +32 -0
- python_cqrs-4.4.2/src/cqrs/requests/mermaid.py +307 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/requests/request.py +3 -0
- python_cqrs-4.4.2/src/cqrs/requests/request_handler.py +93 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/response.py +0 -1
- python_cqrs-4.4.2/src/cqrs/saga/__init__.py +0 -0
- python_cqrs-4.4.2/src/cqrs/saga/mermaid.py +287 -0
- python_cqrs-4.4.2/src/cqrs/saga/models.py +78 -0
- python_cqrs-4.4.2/src/cqrs/saga/recovery.py +111 -0
- python_cqrs-4.4.2/src/cqrs/saga/saga.py +577 -0
- python_cqrs-4.4.2/src/cqrs/saga/step.py +179 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/__init__.py +10 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/enums.py +22 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/memory.py +106 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/models.py +35 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/protocol.py +60 -0
- python_cqrs-4.4.2/src/cqrs/saga/storage/sqlalchemy.py +213 -0
- python_cqrs-4.4.2/src/cqrs/types.py +18 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/PKG-INFO +181 -19
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/SOURCES.txt +20 -1
- python_cqrs-4.3.1/src/cqrs/deserializers/__init__.py +0 -12
- python_cqrs-4.3.1/src/cqrs/dispatcher/__init__.py +0 -13
- python_cqrs-4.3.1/src/cqrs/dispatcher/dispatcher.py +0 -205
- python_cqrs-4.3.1/src/cqrs/events/event_handler.py +0 -46
- python_cqrs-4.3.1/src/cqrs/middlewares/base.py +0 -30
- python_cqrs-4.3.1/src/cqrs/requests/__init__.py +0 -17
- python_cqrs-4.3.1/src/cqrs/requests/cor_request_handler.py +0 -124
- python_cqrs-4.3.1/src/cqrs/requests/map.py +0 -30
- python_cqrs-4.3.1/src/cqrs/requests/request_handler.py +0 -184
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/LICENSE +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/setup.cfg +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/di.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/events/bootstrap.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/producer.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/cqrs/serializers/protobuf.py +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-4.3.1 → python_cqrs-4.4.2}/src/python_cqrs.egg-info/requires.txt +0 -0
- {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
|
+
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
10
|
-
|
|
9
|
+
from cqrs.deserializers.exceptions import DeserializeProtobufError
|
|
11
10
|
|
|
12
|
-
|
|
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,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)
|