python-cqrs 4.12.0__tar.gz → 4.13.0__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.12.0 → python_cqrs-4.13.0}/PKG-INFO +32 -6
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/README.md +27 -3
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/pyproject.toml +5 -3
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/__init__.py +14 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/outbox/sqlalchemy.py +16 -41
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/sqlalchemy.py +3 -1
- python_cqrs-4.13.0/src/cqrs/sqlalchemy_types.py +237 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/python_cqrs.egg-info/PKG-INFO +32 -6
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/python_cqrs.egg-info/SOURCES.txt +1 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/python_cqrs.egg-info/requires.txt +4 -2
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/LICENSE +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/setup.cfg +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/circuit_breaker.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/container/di.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/deserializers/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/deserializers/exceptions.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/deserializers/json.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/event.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/exceptions.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/models.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/request.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/saga.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/dispatcher/streaming.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/bootstrap.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/event.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/event_emitter.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/event_handler.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/event_processor.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/fallback.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/events/map.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/generic_utils.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/mediator.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/middlewares/base.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/middlewares/logging.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/outbox/map.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/outbox/mock.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/outbox/repository.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/producer.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/bootstrap.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/cor_request_handler.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/fallback.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/map.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/mermaid.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/request.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/requests/request_handler.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/response.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/bootstrap.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/circuit_breaker.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/compensation.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/execution.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/fallback.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/mermaid.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/models.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/recovery.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/saga.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/step.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/enums.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/memory.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/models.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/storage/protocol.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/saga/validation.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/cqrs/types.py +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-4.12.0 → python_cqrs-4.13.0}/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.13.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>
|
|
@@ -42,7 +42,8 @@ Requires-Dist: requests==2.*; extra == "dev"
|
|
|
42
42
|
Requires-Dist: pytest~=7.4.2; extra == "dev"
|
|
43
43
|
Requires-Dist: pytest-asyncio~=0.21.1; extra == "dev"
|
|
44
44
|
Requires-Dist: pytest-env==0.6.2; extra == "dev"
|
|
45
|
-
Requires-Dist:
|
|
45
|
+
Requires-Dist: alembic>=1.13.0; extra == "dev"
|
|
46
|
+
Requires-Dist: cryptography==50.0.0; extra == "dev"
|
|
46
47
|
Requires-Dist: asyncmy==0.2.9; extra == "dev"
|
|
47
48
|
Requires-Dist: asyncpg>=0.29.0; extra == "dev"
|
|
48
49
|
Requires-Dist: redis>=5.0.0; extra == "dev"
|
|
@@ -52,8 +53,9 @@ Requires-Dist: fastapi==0.109.*; extra == "examples"
|
|
|
52
53
|
Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
|
|
53
54
|
Requires-Dist: faker>=37.12.0; extra == "examples"
|
|
54
55
|
Requires-Dist: uvicorn==0.32.0; extra == "examples"
|
|
55
|
-
Requires-Dist: aiohttp==3.14.
|
|
56
|
+
Requires-Dist: aiohttp==3.14.3; extra == "examples"
|
|
56
57
|
Requires-Dist: protobuf>=4.25.8; extra == "examples"
|
|
58
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == "examples"
|
|
57
59
|
Provides-Extra: kafka
|
|
58
60
|
Requires-Dist: aiokafka==0.10.0; extra == "kafka"
|
|
59
61
|
Requires-Dist: retry-async==0.1.*; extra == "kafka"
|
|
@@ -108,6 +110,7 @@ Dynamic: license-file
|
|
|
108
110
|
## Table of Contents
|
|
109
111
|
|
|
110
112
|
- [Overview](#overview)
|
|
113
|
+
- [When NOT to use python-cqrs](#when-not-to-use-python-cqrs)
|
|
111
114
|
- [Installation](#installation)
|
|
112
115
|
- [Quick Start](#quick-start)
|
|
113
116
|
- [Request and Response Types](#request-and-response-types)
|
|
@@ -150,10 +153,21 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
|
|
|
150
153
|
|
|
151
154
|
- **Typing:** Pydantic [v2.*](https://docs.pydantic.dev/2.8/) and `IRequest`/`IResponse` interfaces — use Pydantic-based, dataclass-based, or custom Request/Response implementations.
|
|
152
155
|
- **Broker:** Kafka via [aiokafka](https://github.com/aio-libs/aiokafka).
|
|
156
|
+
- **Storage:** SQLAlchemy-based Outbox and Saga storage on MySQL/MariaDB and PostgreSQL.
|
|
153
157
|
- **Integration:** Ready for integration with FastAPI and FastStream.
|
|
154
158
|
- **Documentation:** Built-in Mermaid diagram generation (Sequence and Class diagrams).
|
|
155
159
|
- **Protobuf:** Interface-level support for converting Notification events to Protobuf and back.
|
|
156
160
|
|
|
161
|
+
## When NOT to use python-cqrs
|
|
162
|
+
|
|
163
|
+
CQRS, Outbox, and Saga are tools for specific problems — not a default for every service.
|
|
164
|
+
|
|
165
|
+
- **Prefer a local DB transaction** for single-service CRUD (one database, no broker, no compensation).
|
|
166
|
+
- **Use [Transactional Outbox](#transactional-outbox)** when you dual-write to a database **and** a message broker. Commit the business row and the outbox row together; a publisher drains the outbox later.
|
|
167
|
+
- **Use [Saga](#saga-pattern)** when a multi-step flow crosses service/database boundaries and a later step must be compensated if it fails.
|
|
168
|
+
|
|
169
|
+
Do not add a Saga for a single `UPDATE` in one service, and do not use a Saga as a substitute for Outbox. Full write-up: [When NOT to use python-cqrs](docs/when_not_to_use.md).
|
|
170
|
+
|
|
157
171
|
## Installation
|
|
158
172
|
|
|
159
173
|
**Python 3.10+** is required.
|
|
@@ -860,10 +874,19 @@ class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand, None]):
|
|
|
860
874
|
A complete example can be found in
|
|
861
875
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/save_events_into_outbox.py)
|
|
862
876
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
> By default, it is set to `outbox`.
|
|
877
|
+
A runnable FastAPI flow (route → command → transactional outbox → publisher stub) is in
|
|
878
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
866
879
|
|
|
880
|
+
> [!TIP]
|
|
881
|
+
> The Outbox table is named `outbox` by default. To rename it, or to attach the model to your own declarative
|
|
882
|
+
> base, use `cqrs.rebind_outbox_model(OutboxModel, Base, table_name="my_outbox_table")` at startup — before
|
|
883
|
+
> `create_all()`, before generating migrations and before the first query.
|
|
884
|
+
> [!TIP]
|
|
885
|
+
> `SqlAlchemyOutboxedEventRepository` works on **MySQL/MariaDB** and **PostgreSQL**: the outbox columns are
|
|
886
|
+
> rendered with dialect-aware types (`BINARY(16)` on MySQL, `UUID`/`BYTEA` on PostgreSQL), with a portable
|
|
887
|
+
> `BLOB` fallback for other dialects. Switching databases is a DSN change only — see the
|
|
888
|
+
> [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
|
|
889
|
+
> how to register a native type for your own database.
|
|
867
890
|
> [!TIP]
|
|
868
891
|
> If you use the protobuf events you should specify `OutboxedEventRepository`
|
|
869
892
|
> by [protobuf serialize](https://github.com/vadikko2/python-cqrs/blob/master/src/cqrs/serializers/protobuf.py). A complete example can be found in
|
|
@@ -1019,6 +1042,9 @@ async def join_metting(
|
|
|
1019
1042
|
A complete example can be found in
|
|
1020
1043
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_integration.py)
|
|
1021
1044
|
|
|
1045
|
+
End-to-end command + outbox sample (business row and outbox event in one transaction, plus a publisher stub):
|
|
1046
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
1047
|
+
|
|
1022
1048
|
### Kafka events consuming
|
|
1023
1049
|
|
|
1024
1050
|
If you build interaction by events over broker like `Kafka`, you can to implement an event consumer on your
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
## Table of Contents
|
|
44
44
|
|
|
45
45
|
- [Overview](#overview)
|
|
46
|
+
- [When NOT to use python-cqrs](#when-not-to-use-python-cqrs)
|
|
46
47
|
- [Installation](#installation)
|
|
47
48
|
- [Quick Start](#quick-start)
|
|
48
49
|
- [Request and Response Types](#request-and-response-types)
|
|
@@ -85,10 +86,21 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
|
|
|
85
86
|
|
|
86
87
|
- **Typing:** Pydantic [v2.*](https://docs.pydantic.dev/2.8/) and `IRequest`/`IResponse` interfaces — use Pydantic-based, dataclass-based, or custom Request/Response implementations.
|
|
87
88
|
- **Broker:** Kafka via [aiokafka](https://github.com/aio-libs/aiokafka).
|
|
89
|
+
- **Storage:** SQLAlchemy-based Outbox and Saga storage on MySQL/MariaDB and PostgreSQL.
|
|
88
90
|
- **Integration:** Ready for integration with FastAPI and FastStream.
|
|
89
91
|
- **Documentation:** Built-in Mermaid diagram generation (Sequence and Class diagrams).
|
|
90
92
|
- **Protobuf:** Interface-level support for converting Notification events to Protobuf and back.
|
|
91
93
|
|
|
94
|
+
## When NOT to use python-cqrs
|
|
95
|
+
|
|
96
|
+
CQRS, Outbox, and Saga are tools for specific problems — not a default for every service.
|
|
97
|
+
|
|
98
|
+
- **Prefer a local DB transaction** for single-service CRUD (one database, no broker, no compensation).
|
|
99
|
+
- **Use [Transactional Outbox](#transactional-outbox)** when you dual-write to a database **and** a message broker. Commit the business row and the outbox row together; a publisher drains the outbox later.
|
|
100
|
+
- **Use [Saga](#saga-pattern)** when a multi-step flow crosses service/database boundaries and a later step must be compensated if it fails.
|
|
101
|
+
|
|
102
|
+
Do not add a Saga for a single `UPDATE` in one service, and do not use a Saga as a substitute for Outbox. Full write-up: [When NOT to use python-cqrs](docs/when_not_to_use.md).
|
|
103
|
+
|
|
92
104
|
## Installation
|
|
93
105
|
|
|
94
106
|
**Python 3.10+** is required.
|
|
@@ -795,10 +807,19 @@ class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand, None]):
|
|
|
795
807
|
A complete example can be found in
|
|
796
808
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/save_events_into_outbox.py)
|
|
797
809
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
> By default, it is set to `outbox`.
|
|
810
|
+
A runnable FastAPI flow (route → command → transactional outbox → publisher stub) is in
|
|
811
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
801
812
|
|
|
813
|
+
> [!TIP]
|
|
814
|
+
> The Outbox table is named `outbox` by default. To rename it, or to attach the model to your own declarative
|
|
815
|
+
> base, use `cqrs.rebind_outbox_model(OutboxModel, Base, table_name="my_outbox_table")` at startup — before
|
|
816
|
+
> `create_all()`, before generating migrations and before the first query.
|
|
817
|
+
> [!TIP]
|
|
818
|
+
> `SqlAlchemyOutboxedEventRepository` works on **MySQL/MariaDB** and **PostgreSQL**: the outbox columns are
|
|
819
|
+
> rendered with dialect-aware types (`BINARY(16)` on MySQL, `UUID`/`BYTEA` on PostgreSQL), with a portable
|
|
820
|
+
> `BLOB` fallback for other dialects. Switching databases is a DSN change only — see the
|
|
821
|
+
> [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
|
|
822
|
+
> how to register a native type for your own database.
|
|
802
823
|
> [!TIP]
|
|
803
824
|
> If you use the protobuf events you should specify `OutboxedEventRepository`
|
|
804
825
|
> by [protobuf serialize](https://github.com/vadikko2/python-cqrs/blob/master/src/cqrs/serializers/protobuf.py). A complete example can be found in
|
|
@@ -954,6 +975,9 @@ async def join_metting(
|
|
|
954
975
|
A complete example can be found in
|
|
955
976
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_integration.py)
|
|
956
977
|
|
|
978
|
+
End-to-end command + outbox sample (business row and outbox event in one transaction, plus a publisher stub):
|
|
979
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
980
|
+
|
|
957
981
|
### Kafka events consuming
|
|
958
982
|
|
|
959
983
|
If you build interaction by events over broker like `Kafka`, you can to implement an event consumer on your
|
|
@@ -31,7 +31,7 @@ maintainers = [{ name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru" }]
|
|
|
31
31
|
name = "python-cqrs"
|
|
32
32
|
readme = "README.md"
|
|
33
33
|
requires-python = ">=3.10"
|
|
34
|
-
version = "4.
|
|
34
|
+
version = "4.13.0"
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
aiobreaker = ["aiobreaker>=0.3.0"]
|
|
@@ -52,7 +52,8 @@ dev = [
|
|
|
52
52
|
"pytest~=7.4.2",
|
|
53
53
|
"pytest-asyncio~=0.21.1",
|
|
54
54
|
"pytest-env==0.6.2",
|
|
55
|
-
"
|
|
55
|
+
"alembic>=1.13.0", # from outbox migrations tests
|
|
56
|
+
"cryptography==50.0.0",
|
|
56
57
|
"asyncmy==0.2.9",
|
|
57
58
|
"asyncpg>=0.29.0",
|
|
58
59
|
"redis>=5.0.0",
|
|
@@ -64,8 +65,9 @@ examples = [
|
|
|
64
65
|
"faststream[kafka]==0.5.28",
|
|
65
66
|
"faker>=37.12.0",
|
|
66
67
|
"uvicorn==0.32.0",
|
|
67
|
-
"aiohttp==3.14.
|
|
68
|
+
"aiohttp==3.14.3",
|
|
68
69
|
"protobuf>=4.25.8",
|
|
70
|
+
"aiosqlite>=0.20.0",
|
|
69
71
|
]
|
|
70
72
|
kafka = ["aiokafka==0.10.0", "retry-async==0.1.*"]
|
|
71
73
|
rabbit = ["aio-pika==9.3.0"]
|
|
@@ -54,6 +54,14 @@ from cqrs.saga.step import (
|
|
|
54
54
|
SagaStepHandler,
|
|
55
55
|
SagaStepResult,
|
|
56
56
|
)
|
|
57
|
+
from cqrs.sqlalchemy_types import (
|
|
58
|
+
Binary16,
|
|
59
|
+
DialectAwareType,
|
|
60
|
+
DialectTypeHandler,
|
|
61
|
+
JSONType,
|
|
62
|
+
PayloadBinary,
|
|
63
|
+
UUIDBinary,
|
|
64
|
+
)
|
|
57
65
|
|
|
58
66
|
__all__ = (
|
|
59
67
|
"ICircuitBreaker",
|
|
@@ -108,4 +116,10 @@ __all__ = (
|
|
|
108
116
|
"ContextT",
|
|
109
117
|
"SagaMermaid",
|
|
110
118
|
"CoRMermaid",
|
|
119
|
+
"DialectAwareType",
|
|
120
|
+
"DialectTypeHandler",
|
|
121
|
+
"UUIDBinary",
|
|
122
|
+
"Binary16",
|
|
123
|
+
"JSONType",
|
|
124
|
+
"PayloadBinary",
|
|
111
125
|
)
|
|
@@ -15,7 +15,8 @@ try:
|
|
|
15
15
|
from sqlalchemy import func
|
|
16
16
|
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeMeta, registry
|
|
17
17
|
from sqlalchemy.ext.asyncio import session as sql_session
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
from cqrs import sqlalchemy_types
|
|
19
20
|
except ImportError:
|
|
20
21
|
raise ImportError(
|
|
21
22
|
"You are trying to use SQLAlchemy outbox implementation, "
|
|
@@ -35,37 +36,10 @@ DEFAULT_OUTBOX_TABLE_NAME = "outbox"
|
|
|
35
36
|
MAX_FLUSH_COUNTER_VALUE = 5
|
|
36
37
|
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
cache_ok = True
|
|
43
|
-
|
|
44
|
-
def load_dialect_impl(self, dialect):
|
|
45
|
-
if dialect.name == "postgresql":
|
|
46
|
-
return dialect.type_descriptor(postgresql.UUID())
|
|
47
|
-
else:
|
|
48
|
-
return dialect.type_descriptor(sqlalchemy.BINARY(16))
|
|
49
|
-
|
|
50
|
-
def process_bind_param(self, value, dialect):
|
|
51
|
-
if value is None:
|
|
52
|
-
return value
|
|
53
|
-
if isinstance(value, str):
|
|
54
|
-
value = uuid.UUID(value)
|
|
55
|
-
if dialect.name == "postgresql":
|
|
56
|
-
return value # asyncpg works with uuid.UUID
|
|
57
|
-
if isinstance(value, uuid.UUID):
|
|
58
|
-
return value.bytes # For MySQL return 16 bytes
|
|
59
|
-
return value
|
|
60
|
-
|
|
61
|
-
def process_result_value(self, value, dialect):
|
|
62
|
-
if value is None:
|
|
63
|
-
return value
|
|
64
|
-
if dialect.name == "postgresql":
|
|
65
|
-
return value # asyncpg return uuid.UUID
|
|
66
|
-
if isinstance(value, bytes):
|
|
67
|
-
return uuid.UUID(bytes=value) # From MySQL got bytes, make UUID
|
|
68
|
-
return value
|
|
39
|
+
# Deprecated name of cqrs.sqlalchemy_types.UUIDBinary — kept importable for
|
|
40
|
+
# backward compatibility (e.g. already generated Alembic migrations).
|
|
41
|
+
# Prefer cqrs.sqlalchemy_types.UUIDBinary in new code.
|
|
42
|
+
BinaryUUID = sqlalchemy_types.UUIDBinary
|
|
69
43
|
|
|
70
44
|
|
|
71
45
|
class OutboxModel(Base):
|
|
@@ -87,12 +61,12 @@ class OutboxModel(Base):
|
|
|
87
61
|
comment="Identity",
|
|
88
62
|
)
|
|
89
63
|
event_id: Mapped[uuid.UUID] = mapped_column(
|
|
90
|
-
|
|
64
|
+
sqlalchemy_types.UUIDBinary,
|
|
91
65
|
nullable=False,
|
|
92
66
|
comment="Event idempotency id",
|
|
93
67
|
)
|
|
94
68
|
event_id_bin: Mapped[bytes] = mapped_column(
|
|
95
|
-
|
|
69
|
+
sqlalchemy_types.Binary16,
|
|
96
70
|
nullable=False,
|
|
97
71
|
comment="Event idempotency id in 16 bit presentation",
|
|
98
72
|
)
|
|
@@ -126,7 +100,7 @@ class OutboxModel(Base):
|
|
|
126
100
|
comment="Event creation timestamp",
|
|
127
101
|
)
|
|
128
102
|
payload: Mapped[bytes] = mapped_column(
|
|
129
|
-
|
|
103
|
+
sqlalchemy_types.PayloadBinary,
|
|
130
104
|
nullable=False,
|
|
131
105
|
comment="Event payload",
|
|
132
106
|
)
|
|
@@ -177,13 +151,14 @@ class OutboxModel(Base):
|
|
|
177
151
|
|
|
178
152
|
@classmethod
|
|
179
153
|
def status_sorting_case(cls) -> sqlalchemy.Case:
|
|
154
|
+
# Conditions instead of a `value=` mapping: only a comparison with the
|
|
155
|
+
# column binds the statuses through the Enum type. As plain mapping keys
|
|
156
|
+
# they are sent as enum values ("new"), which PostgreSQL rejects because
|
|
157
|
+
# its `eventstatus` type holds member names ("NEW").
|
|
180
158
|
return sqlalchemy.case(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
repository.EventStatus.PRODUCED: 3,
|
|
185
|
-
},
|
|
186
|
-
value=cls.event_status,
|
|
159
|
+
(cls.event_status == repository.EventStatus.NEW, 1),
|
|
160
|
+
(cls.event_status == repository.EventStatus.NOT_PRODUCED, 2),
|
|
161
|
+
(cls.event_status == repository.EventStatus.PRODUCED, 3),
|
|
187
162
|
else_=4,
|
|
188
163
|
)
|
|
189
164
|
|
|
@@ -17,6 +17,8 @@ try:
|
|
|
17
17
|
from sqlalchemy.exc import SQLAlchemyError
|
|
18
18
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
19
19
|
from sqlalchemy.orm import registry
|
|
20
|
+
|
|
21
|
+
from cqrs.sqlalchemy_types import JSONType
|
|
20
22
|
except ImportError:
|
|
21
23
|
raise ImportError(
|
|
22
24
|
"You are trying to use SQLAlchemy saga storage implementation, "
|
|
@@ -63,7 +65,7 @@ class SagaExecutionModel(Base):
|
|
|
63
65
|
comment="Current status of the saga",
|
|
64
66
|
)
|
|
65
67
|
context = sqlalchemy.Column(
|
|
66
|
-
|
|
68
|
+
JSONType,
|
|
67
69
|
nullable=False,
|
|
68
70
|
comment="Serialized context",
|
|
69
71
|
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Dialect-aware SQLAlchemy types shared by the library models.
|
|
2
|
+
|
|
3
|
+
The module generalizes the trick used by the outbox model: a single Python type
|
|
4
|
+
is rendered as the most suitable native type of the current dialect. Mapping
|
|
5
|
+
between a dialect and its DDL type/value conversion is kept in a per-class
|
|
6
|
+
registry, so support for a new DBMS is one `register_dialect` call away.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import typing
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import sqlalchemy
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.dialects import mysql, postgresql
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"You are trying to use SQLAlchemy types, "
|
|
20
|
+
"but 'sqlalchemy' is not installed. "
|
|
21
|
+
"Please install it using: pip install python-cqrs[sqlalchemy]",
|
|
22
|
+
) from None
|
|
23
|
+
|
|
24
|
+
__all__ = (
|
|
25
|
+
"Binary16",
|
|
26
|
+
"DialectAwareType",
|
|
27
|
+
"DialectTypeHandler",
|
|
28
|
+
"JSONType",
|
|
29
|
+
"PayloadBinary",
|
|
30
|
+
"UUIDBinary",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class DialectTypeHandler:
|
|
36
|
+
"""Describes how a type lives in a particular dialect.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
type_factory: builds the dialect native type used in DDL and binding.
|
|
40
|
+
bind: converts a Python value into the dialect representation.
|
|
41
|
+
result: converts a value fetched from the database into a Python one.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
type_factory: typing.Callable[[typing.Any], typing.Any]
|
|
45
|
+
bind: typing.Callable[[typing.Any], typing.Any] | None = None
|
|
46
|
+
result: typing.Callable[[typing.Any], typing.Any] | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DialectAwareType(sqlalchemy.TypeDecorator):
|
|
50
|
+
"""Base class for types resolved through a per-dialect registry.
|
|
51
|
+
|
|
52
|
+
Subclasses declare a `default_handler` used as a portable fallback and
|
|
53
|
+
register dialect specific handlers via `register_dialect`. Every subclass
|
|
54
|
+
owns its registry, so registrations never leak between types.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
impl = sqlalchemy.LargeBinary
|
|
58
|
+
cache_ok = True
|
|
59
|
+
|
|
60
|
+
default_handler: typing.ClassVar[DialectTypeHandler]
|
|
61
|
+
_handlers: typing.ClassVar[typing.Dict[typing.Text, DialectTypeHandler]] = {}
|
|
62
|
+
|
|
63
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
64
|
+
super().__init_subclass__(**kwargs)
|
|
65
|
+
# Each subclass owns its registry, otherwise a registration made for one
|
|
66
|
+
# type would leak into all the others.
|
|
67
|
+
cls._handlers = dict(cls._handlers)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def register_dialect(
|
|
71
|
+
cls,
|
|
72
|
+
dialect_name: typing.Text,
|
|
73
|
+
handler: DialectTypeHandler,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Registers (or overrides) the handler used for `dialect_name`."""
|
|
76
|
+
cls._handlers[dialect_name] = handler
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_handler(cls, dialect_name: typing.Text) -> DialectTypeHandler:
|
|
80
|
+
"""Returns the handler for `dialect_name` or the portable fallback."""
|
|
81
|
+
return cls._handlers.get(dialect_name, cls.default_handler)
|
|
82
|
+
|
|
83
|
+
def load_dialect_impl(self, dialect):
|
|
84
|
+
handler = self.get_handler(dialect.name)
|
|
85
|
+
return dialect.type_descriptor(handler.type_factory(dialect))
|
|
86
|
+
|
|
87
|
+
def process_bind_param(self, value, dialect):
|
|
88
|
+
if value is None:
|
|
89
|
+
return value
|
|
90
|
+
handler = self.get_handler(dialect.name)
|
|
91
|
+
if handler.bind is None:
|
|
92
|
+
return value
|
|
93
|
+
return handler.bind(value)
|
|
94
|
+
|
|
95
|
+
def process_result_value(self, value, dialect):
|
|
96
|
+
if value is None:
|
|
97
|
+
return value
|
|
98
|
+
handler = self.get_handler(dialect.name)
|
|
99
|
+
if handler.result is None:
|
|
100
|
+
return value
|
|
101
|
+
return handler.result(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _as_uuid(value: typing.Any) -> typing.Any:
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return uuid.UUID(value)
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _as_bytes(value: typing.Any) -> typing.Any:
|
|
111
|
+
value = _as_uuid(value)
|
|
112
|
+
if isinstance(value, uuid.UUID):
|
|
113
|
+
return value.bytes
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _as_uuid_from_bytes(value: typing.Any) -> typing.Any:
|
|
118
|
+
if isinstance(value, bytes):
|
|
119
|
+
return uuid.UUID(bytes=value)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class UUIDBinary(DialectAwareType):
|
|
124
|
+
"""`uuid.UUID` stored as a native UUID in PostgreSQL and as BINARY(16) elsewhere."""
|
|
125
|
+
|
|
126
|
+
default_handler = DialectTypeHandler(
|
|
127
|
+
type_factory=lambda dialect: sqlalchemy.LargeBinary(16),
|
|
128
|
+
bind=_as_bytes,
|
|
129
|
+
result=_as_uuid_from_bytes,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
UUIDBinary.register_dialect(
|
|
134
|
+
"postgresql",
|
|
135
|
+
DialectTypeHandler(
|
|
136
|
+
# asyncpg works with uuid.UUID, no conversion is needed.
|
|
137
|
+
type_factory=lambda dialect: postgresql.UUID(as_uuid=True),
|
|
138
|
+
bind=_as_uuid,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
UUIDBinary.register_dialect(
|
|
142
|
+
"mysql",
|
|
143
|
+
DialectTypeHandler(
|
|
144
|
+
type_factory=lambda dialect: mysql.BINARY(16),
|
|
145
|
+
bind=_as_bytes,
|
|
146
|
+
result=_as_uuid_from_bytes,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
UUIDBinary.register_dialect("mariadb", UUIDBinary.get_handler("mysql"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Binary16(DialectAwareType):
|
|
153
|
+
"""Raw 16 bytes: BYTEA in PostgreSQL, BINARY(16) in MySQL, LargeBinary(16) elsewhere."""
|
|
154
|
+
|
|
155
|
+
default_handler = DialectTypeHandler(
|
|
156
|
+
type_factory=lambda dialect: sqlalchemy.LargeBinary(16),
|
|
157
|
+
bind=_as_bytes,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
Binary16.register_dialect(
|
|
162
|
+
"postgresql",
|
|
163
|
+
DialectTypeHandler(
|
|
164
|
+
type_factory=lambda dialect: postgresql.BYTEA(),
|
|
165
|
+
bind=_as_bytes,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
Binary16.register_dialect(
|
|
169
|
+
"mysql",
|
|
170
|
+
DialectTypeHandler(
|
|
171
|
+
type_factory=lambda dialect: mysql.BINARY(16),
|
|
172
|
+
bind=_as_bytes,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
Binary16.register_dialect("mariadb", Binary16.get_handler("mysql"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class JSONType(DialectAwareType):
|
|
179
|
+
"""JSON document rendered as plain `JSON` on every dialect.
|
|
180
|
+
|
|
181
|
+
The default handler intentionally repeats `sqlalchemy.JSON`, so switching a
|
|
182
|
+
column to this type does not change the generated DDL anywhere. It only adds
|
|
183
|
+
an extension point: a project that wants a native PostgreSQL `JSONB` enables
|
|
184
|
+
it with a single registration, without touching the library models.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from sqlalchemy.dialects import postgresql
|
|
188
|
+
from cqrs.sqlalchemy_types import DialectTypeHandler, JSONType
|
|
189
|
+
|
|
190
|
+
JSONType.register_dialect(
|
|
191
|
+
"postgresql",
|
|
192
|
+
DialectTypeHandler(type_factory=lambda dialect: postgresql.JSONB()),
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
No value conversion is performed: serialization stays the responsibility of
|
|
197
|
+
the dialect level JSON type, exactly as with `sqlalchemy.JSON`.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
impl = sqlalchemy.JSON
|
|
201
|
+
cache_ok = True
|
|
202
|
+
|
|
203
|
+
default_handler = DialectTypeHandler(
|
|
204
|
+
type_factory=lambda dialect: sqlalchemy.JSON(),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class PayloadBinary(DialectAwareType):
|
|
209
|
+
"""Binary blob of an arbitrary length rendered as plain `LargeBinary` everywhere.
|
|
210
|
+
|
|
211
|
+
The default handler intentionally repeats `sqlalchemy.LargeBinary` without a
|
|
212
|
+
length, so switching a column to this type keeps the DDL identical on every
|
|
213
|
+
dialect (`BYTEA` in PostgreSQL, `BLOB` in MySQL and SQLite). It only adds an
|
|
214
|
+
extension point for a DBMS the library knows nothing about: Microsoft SQL
|
|
215
|
+
Server, for instance, maps `LargeBinary` to the deprecated `IMAGE`, which a
|
|
216
|
+
project fixes with a single registration.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from sqlalchemy.dialects import mssql
|
|
220
|
+
from cqrs.sqlalchemy_types import DialectTypeHandler, PayloadBinary
|
|
221
|
+
|
|
222
|
+
PayloadBinary.register_dialect(
|
|
223
|
+
"mssql",
|
|
224
|
+
DialectTypeHandler(type_factory=lambda dialect: mssql.VARBINARY("max")),
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
No value conversion is performed: the column holds `bytes` and they are
|
|
229
|
+
passed to the driver as is.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
impl = sqlalchemy.LargeBinary
|
|
233
|
+
cache_ok = True
|
|
234
|
+
|
|
235
|
+
default_handler = DialectTypeHandler(
|
|
236
|
+
type_factory=lambda dialect: sqlalchemy.LargeBinary(),
|
|
237
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.13.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>
|
|
@@ -42,7 +42,8 @@ Requires-Dist: requests==2.*; extra == "dev"
|
|
|
42
42
|
Requires-Dist: pytest~=7.4.2; extra == "dev"
|
|
43
43
|
Requires-Dist: pytest-asyncio~=0.21.1; extra == "dev"
|
|
44
44
|
Requires-Dist: pytest-env==0.6.2; extra == "dev"
|
|
45
|
-
Requires-Dist:
|
|
45
|
+
Requires-Dist: alembic>=1.13.0; extra == "dev"
|
|
46
|
+
Requires-Dist: cryptography==50.0.0; extra == "dev"
|
|
46
47
|
Requires-Dist: asyncmy==0.2.9; extra == "dev"
|
|
47
48
|
Requires-Dist: asyncpg>=0.29.0; extra == "dev"
|
|
48
49
|
Requires-Dist: redis>=5.0.0; extra == "dev"
|
|
@@ -52,8 +53,9 @@ Requires-Dist: fastapi==0.109.*; extra == "examples"
|
|
|
52
53
|
Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
|
|
53
54
|
Requires-Dist: faker>=37.12.0; extra == "examples"
|
|
54
55
|
Requires-Dist: uvicorn==0.32.0; extra == "examples"
|
|
55
|
-
Requires-Dist: aiohttp==3.14.
|
|
56
|
+
Requires-Dist: aiohttp==3.14.3; extra == "examples"
|
|
56
57
|
Requires-Dist: protobuf>=4.25.8; extra == "examples"
|
|
58
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == "examples"
|
|
57
59
|
Provides-Extra: kafka
|
|
58
60
|
Requires-Dist: aiokafka==0.10.0; extra == "kafka"
|
|
59
61
|
Requires-Dist: retry-async==0.1.*; extra == "kafka"
|
|
@@ -108,6 +110,7 @@ Dynamic: license-file
|
|
|
108
110
|
## Table of Contents
|
|
109
111
|
|
|
110
112
|
- [Overview](#overview)
|
|
113
|
+
- [When NOT to use python-cqrs](#when-not-to-use-python-cqrs)
|
|
111
114
|
- [Installation](#installation)
|
|
112
115
|
- [Quick Start](#quick-start)
|
|
113
116
|
- [Request and Response Types](#request-and-response-types)
|
|
@@ -150,10 +153,21 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
|
|
|
150
153
|
|
|
151
154
|
- **Typing:** Pydantic [v2.*](https://docs.pydantic.dev/2.8/) and `IRequest`/`IResponse` interfaces — use Pydantic-based, dataclass-based, or custom Request/Response implementations.
|
|
152
155
|
- **Broker:** Kafka via [aiokafka](https://github.com/aio-libs/aiokafka).
|
|
156
|
+
- **Storage:** SQLAlchemy-based Outbox and Saga storage on MySQL/MariaDB and PostgreSQL.
|
|
153
157
|
- **Integration:** Ready for integration with FastAPI and FastStream.
|
|
154
158
|
- **Documentation:** Built-in Mermaid diagram generation (Sequence and Class diagrams).
|
|
155
159
|
- **Protobuf:** Interface-level support for converting Notification events to Protobuf and back.
|
|
156
160
|
|
|
161
|
+
## When NOT to use python-cqrs
|
|
162
|
+
|
|
163
|
+
CQRS, Outbox, and Saga are tools for specific problems — not a default for every service.
|
|
164
|
+
|
|
165
|
+
- **Prefer a local DB transaction** for single-service CRUD (one database, no broker, no compensation).
|
|
166
|
+
- **Use [Transactional Outbox](#transactional-outbox)** when you dual-write to a database **and** a message broker. Commit the business row and the outbox row together; a publisher drains the outbox later.
|
|
167
|
+
- **Use [Saga](#saga-pattern)** when a multi-step flow crosses service/database boundaries and a later step must be compensated if it fails.
|
|
168
|
+
|
|
169
|
+
Do not add a Saga for a single `UPDATE` in one service, and do not use a Saga as a substitute for Outbox. Full write-up: [When NOT to use python-cqrs](docs/when_not_to_use.md).
|
|
170
|
+
|
|
157
171
|
## Installation
|
|
158
172
|
|
|
159
173
|
**Python 3.10+** is required.
|
|
@@ -860,10 +874,19 @@ class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand, None]):
|
|
|
860
874
|
A complete example can be found in
|
|
861
875
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/save_events_into_outbox.py)
|
|
862
876
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
> By default, it is set to `outbox`.
|
|
877
|
+
A runnable FastAPI flow (route → command → transactional outbox → publisher stub) is in
|
|
878
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
866
879
|
|
|
880
|
+
> [!TIP]
|
|
881
|
+
> The Outbox table is named `outbox` by default. To rename it, or to attach the model to your own declarative
|
|
882
|
+
> base, use `cqrs.rebind_outbox_model(OutboxModel, Base, table_name="my_outbox_table")` at startup — before
|
|
883
|
+
> `create_all()`, before generating migrations and before the first query.
|
|
884
|
+
> [!TIP]
|
|
885
|
+
> `SqlAlchemyOutboxedEventRepository` works on **MySQL/MariaDB** and **PostgreSQL**: the outbox columns are
|
|
886
|
+
> rendered with dialect-aware types (`BINARY(16)` on MySQL, `UUID`/`BYTEA` on PostgreSQL), with a portable
|
|
887
|
+
> `BLOB` fallback for other dialects. Switching databases is a DSN change only — see the
|
|
888
|
+
> [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
|
|
889
|
+
> how to register a native type for your own database.
|
|
867
890
|
> [!TIP]
|
|
868
891
|
> If you use the protobuf events you should specify `OutboxedEventRepository`
|
|
869
892
|
> by [protobuf serialize](https://github.com/vadikko2/python-cqrs/blob/master/src/cqrs/serializers/protobuf.py). A complete example can be found in
|
|
@@ -1019,6 +1042,9 @@ async def join_metting(
|
|
|
1019
1042
|
A complete example can be found in
|
|
1020
1043
|
the [documentation](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_integration.py)
|
|
1021
1044
|
|
|
1045
|
+
End-to-end command + outbox sample (business row and outbox event in one transaction, plus a publisher stub):
|
|
1046
|
+
[fastapi_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/fastapi_outbox.py)
|
|
1047
|
+
|
|
1022
1048
|
### Kafka events consuming
|
|
1023
1049
|
|
|
1024
1050
|
If you build interaction by events over broker like `Kafka`, you can to implement an event consumer on your
|
|
@@ -25,7 +25,8 @@ requests==2.*
|
|
|
25
25
|
pytest~=7.4.2
|
|
26
26
|
pytest-asyncio~=0.21.1
|
|
27
27
|
pytest-env==0.6.2
|
|
28
|
-
|
|
28
|
+
alembic>=1.13.0
|
|
29
|
+
cryptography==50.0.0
|
|
29
30
|
asyncmy==0.2.9
|
|
30
31
|
asyncpg>=0.29.0
|
|
31
32
|
redis>=5.0.0
|
|
@@ -36,8 +37,9 @@ fastapi==0.109.*
|
|
|
36
37
|
faststream[kafka]==0.5.28
|
|
37
38
|
faker>=37.12.0
|
|
38
39
|
uvicorn==0.32.0
|
|
39
|
-
aiohttp==3.14.
|
|
40
|
+
aiohttp==3.14.3
|
|
40
41
|
protobuf>=4.25.8
|
|
42
|
+
aiosqlite>=0.20.0
|
|
41
43
|
|
|
42
44
|
[kafka]
|
|
43
45
|
aiokafka==0.10.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|