python-cqrs 4.14.0__tar.gz → 4.15.1__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 (102) hide show
  1. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/PKG-INFO +17 -15
  2. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/README.md +13 -14
  3. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/pyproject.toml +3 -1
  4. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/__init__.py +16 -0
  5. python_cqrs-4.15.1/src/cqrs/_eol.py +30 -0
  6. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/adapters/kafka.py +27 -6
  7. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/adapters/protocol.py +7 -0
  8. python_cqrs-4.15.1/src/cqrs/deserializers/__init__.py +11 -0
  9. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/deserializers/exceptions.py +16 -0
  10. python_cqrs-4.15.1/src/cqrs/deserializers/protobuf.py +51 -0
  11. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/event_emitter.py +16 -0
  12. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/message_brokers/amqp.py +10 -2
  13. python_cqrs-4.15.1/src/cqrs/message_brokers/kafka.py +45 -0
  14. python_cqrs-4.15.1/src/cqrs/message_brokers/protocol.py +74 -0
  15. python_cqrs-4.15.1/src/cqrs/outbox/map.py +103 -0
  16. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/outbox/mock.py +18 -2
  17. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/outbox/repository.py +4 -0
  18. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/outbox/sqlalchemy.py +39 -11
  19. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/producer.py +8 -0
  20. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/bootstrap.py +22 -0
  21. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/bootstrap.py +8 -0
  22. python_cqrs-4.15.1/src/cqrs/serializers/__init__.py +12 -0
  23. python_cqrs-4.15.1/src/cqrs/serializers/default.py +86 -0
  24. python_cqrs-4.15.1/src/cqrs/serializers/json.py +22 -0
  25. python_cqrs-4.15.1/src/cqrs/serializers/protobuf.py +48 -0
  26. python_cqrs-4.15.1/src/cqrs/serializers/protocol.py +21 -0
  27. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/python_cqrs.egg-info/PKG-INFO +17 -15
  28. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/python_cqrs.egg-info/SOURCES.txt +5 -0
  29. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/python_cqrs.egg-info/requires.txt +4 -0
  30. python_cqrs-4.14.0/src/cqrs/deserializers/__init__.py +0 -8
  31. python_cqrs-4.14.0/src/cqrs/message_brokers/kafka.py +0 -18
  32. python_cqrs-4.14.0/src/cqrs/message_brokers/protocol.py +0 -44
  33. python_cqrs-4.14.0/src/cqrs/outbox/map.py +0 -24
  34. python_cqrs-4.14.0/src/cqrs/serializers/__init__.py +0 -3
  35. python_cqrs-4.14.0/src/cqrs/serializers/default.py +0 -24
  36. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/LICENSE +0 -0
  37. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/setup.cfg +0 -0
  38. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/adapters/__init__.py +0 -0
  39. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/adapters/amqp.py +0 -0
  40. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/adapters/circuit_breaker.py +0 -0
  41. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/circuit_breaker.py +0 -0
  42. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/compressors/__init__.py +0 -0
  43. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/compressors/protocol.py +0 -0
  44. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/compressors/zlib.py +0 -0
  45. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/__init__.py +0 -0
  46. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/dependency_injector.py +0 -0
  47. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/di.py +0 -0
  48. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/dishka.py +0 -0
  49. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/protocol.py +0 -0
  50. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/container/scope.py +0 -0
  51. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/deserializers/json.py +0 -0
  52. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/__init__.py +0 -0
  53. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/event.py +0 -0
  54. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/exceptions.py +0 -0
  55. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/models.py +0 -0
  56. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/request.py +0 -0
  57. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/saga.py +0 -0
  58. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/dispatcher/streaming.py +0 -0
  59. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/__init__.py +0 -0
  60. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/bootstrap.py +0 -0
  61. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/event.py +0 -0
  62. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/event_handler.py +0 -0
  63. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/event_processor.py +0 -0
  64. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/fallback.py +0 -0
  65. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/events/map.py +0 -0
  66. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/generic_utils.py +0 -0
  67. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/mediator.py +0 -0
  68. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/message_brokers/__init__.py +0 -0
  69. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/message_brokers/devnull.py +0 -0
  70. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/middlewares/__init__.py +0 -0
  71. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/middlewares/base.py +0 -0
  72. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/middlewares/logging.py +0 -0
  73. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/outbox/__init__.py +0 -0
  74. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/__init__.py +0 -0
  75. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/cor_request_handler.py +0 -0
  76. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/fallback.py +0 -0
  77. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/map.py +0 -0
  78. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/mermaid.py +0 -0
  79. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/request.py +0 -0
  80. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/requests/request_handler.py +0 -0
  81. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/response.py +0 -0
  82. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/__init__.py +0 -0
  83. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/circuit_breaker.py +0 -0
  84. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/compensation.py +0 -0
  85. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/execution.py +0 -0
  86. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/fallback.py +0 -0
  87. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/mermaid.py +0 -0
  88. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/models.py +0 -0
  89. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/recovery.py +0 -0
  90. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/saga.py +0 -0
  91. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/step.py +0 -0
  92. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/__init__.py +0 -0
  93. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/enums.py +0 -0
  94. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/memory.py +0 -0
  95. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/models.py +0 -0
  96. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/protocol.py +0 -0
  97. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/storage/sqlalchemy.py +0 -0
  98. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/saga/validation.py +0 -0
  99. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/sqlalchemy_types.py +0 -0
  100. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/cqrs/types.py +0 -0
  101. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  102. {python_cqrs-4.14.0 → python_cqrs-4.15.1}/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.14.0
3
+ Version: 4.15.1
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>
@@ -49,6 +49,7 @@ Requires-Dist: asyncpg>=0.29.0; extra == "dev"
49
49
  Requires-Dist: redis>=5.0.0; extra == "dev"
50
50
  Requires-Dist: aiobreaker>=0.3.0; extra == "dev"
51
51
  Requires-Dist: dishka>=1.0; extra == "dev"
52
+ Requires-Dist: protobuf>=4.25.8; extra == "dev"
52
53
  Provides-Extra: examples
53
54
  Requires-Dist: fastapi==0.109.*; extra == "examples"
54
55
  Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
@@ -61,6 +62,8 @@ Requires-Dist: dishka>=1.0; extra == "examples"
61
62
  Provides-Extra: kafka
62
63
  Requires-Dist: aiokafka==0.10.0; extra == "kafka"
63
64
  Requires-Dist: retry-async==0.1.*; extra == "kafka"
65
+ Provides-Extra: protobuf
66
+ Requires-Dist: protobuf>=4.25.8; extra == "protobuf"
64
67
  Provides-Extra: rabbit
65
68
  Requires-Dist: aio-pika==9.3.0; extra == "rabbit"
66
69
  Provides-Extra: sqlalchemy
@@ -91,9 +94,6 @@ Dynamic: license-file
91
94
  <a href="https://codecov.io/gh/vadikko2/python-cqrs">
92
95
  <img src="https://img.shields.io/codecov/c/github/vadikko2/python-cqrs?logo=codecov&logoColor=white" alt="Coverage">
93
96
  </a>
94
- <a href="https://codspeed.io/vadikko2/python-cqrs?utm_source=badge">
95
- <img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed">
96
- </a>
97
97
  <a href="https://mkdocs.python-cqrs.dev/">
98
98
  <img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
99
99
  </a>
@@ -106,11 +106,6 @@ Dynamic: license-file
106
106
  </p>
107
107
  </div>
108
108
 
109
- > [!WARNING]
110
- > **Breaking Changes in v5.0.0**
111
- >
112
- > Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
113
-
114
109
  ## Table of Contents
115
110
 
116
111
  - [Overview](#overview)
@@ -189,6 +184,8 @@ pip install python-cqrs[examples] # FastAPI, FastStream, uvicorn, etc.
189
184
  pip install python-cqrs[aiobreaker] # Circuit breaker for saga fallbacks
190
185
  ```
191
186
 
187
+ Starting with **4.15.1**, importing `cqrs` emits a package-level EOL `DeprecationWarning` toward 5.x (plus an optional log). Suppress the log with `CQRS_SUPPRESS_V4_EOL_WARNING=1`. See [SECURITY.md](SECURITY.md) for the 4.x support window and pin guidance.
188
+
192
189
  ## Quick Start
193
190
 
194
191
  Define a command, a handler, bind them, and run via the mediator:
@@ -923,12 +920,9 @@ A runnable FastAPI flow (route → command → transactional outbox → publishe
923
920
  > [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
924
921
  > how to register a native type for your own database.
925
922
  > [!TIP]
926
- > For Protobuf events, implement `proto()` / `from_proto()` on the notification
927
- > event (see [Protobuf messaging](#protobuf-messaging) and the schema assets in
928
- > [examples/proto/](https://github.com/vadikko2/python-cqrs/tree/master/examples/proto)).
929
- > Outbox persistence is unchanged — use
930
- > [save_events_into_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/save_events_into_outbox.py);
931
- > Protobuf applies when producing to the broker. Full guide:
923
+ > For Protobuf events, implement `proto()` / `from_proto()` and register a
924
+ > `ProtobufEventSerializer` on an `OutboxedEventMap`. JSON stays the default —
925
+ > see [Protobuf messaging](#protobuf-messaging) and the full guide:
932
926
  > [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
933
927
 
934
928
  ## Producing Events from Outbox to Kafka
@@ -1231,6 +1225,14 @@ class UserJoinedNotificationEvent(cqrs.NotificationEvent[UserJoinedPayload]):
1231
1225
  )
1232
1226
  ```
1233
1227
 
1228
+ JSON remains the default. Opt-in Protobuf wiring (`ProtobufEventSerializer` on an
1229
+ `OutboxedEventMap`, `Message.payload_bytes`, consume via `ProtobufDeserializer`) is
1230
+ documented in the full guide:
1231
+ [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
1232
+ Runnable example:
1233
+ [protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py)
1234
+ (`pip install "python-cqrs[protobuf]"`).
1235
+
1234
1236
  ## Contributing
1235
1237
 
1236
1238
  Contributions are welcome. To develop locally:
@@ -20,9 +20,6 @@
20
20
  <a href="https://codecov.io/gh/vadikko2/python-cqrs">
21
21
  <img src="https://img.shields.io/codecov/c/github/vadikko2/python-cqrs?logo=codecov&logoColor=white" alt="Coverage">
22
22
  </a>
23
- <a href="https://codspeed.io/vadikko2/python-cqrs?utm_source=badge">
24
- <img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed">
25
- </a>
26
23
  <a href="https://mkdocs.python-cqrs.dev/">
27
24
  <img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
28
25
  </a>
@@ -35,11 +32,6 @@
35
32
  </p>
36
33
  </div>
37
34
 
38
- > [!WARNING]
39
- > **Breaking Changes in v5.0.0**
40
- >
41
- > Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
42
-
43
35
  ## Table of Contents
44
36
 
45
37
  - [Overview](#overview)
@@ -118,6 +110,8 @@ pip install python-cqrs[examples] # FastAPI, FastStream, uvicorn, etc.
118
110
  pip install python-cqrs[aiobreaker] # Circuit breaker for saga fallbacks
119
111
  ```
120
112
 
113
+ Starting with **4.15.1**, importing `cqrs` emits a package-level EOL `DeprecationWarning` toward 5.x (plus an optional log). Suppress the log with `CQRS_SUPPRESS_V4_EOL_WARNING=1`. See [SECURITY.md](SECURITY.md) for the 4.x support window and pin guidance.
114
+
121
115
  ## Quick Start
122
116
 
123
117
  Define a command, a handler, bind them, and run via the mediator:
@@ -852,12 +846,9 @@ A runnable FastAPI flow (route → command → transactional outbox → publishe
852
846
  > [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
853
847
  > how to register a native type for your own database.
854
848
  > [!TIP]
855
- > For Protobuf events, implement `proto()` / `from_proto()` on the notification
856
- > event (see [Protobuf messaging](#protobuf-messaging) and the schema assets in
857
- > [examples/proto/](https://github.com/vadikko2/python-cqrs/tree/master/examples/proto)).
858
- > Outbox persistence is unchanged — use
859
- > [save_events_into_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/save_events_into_outbox.py);
860
- > Protobuf applies when producing to the broker. Full guide:
849
+ > For Protobuf events, implement `proto()` / `from_proto()` and register a
850
+ > `ProtobufEventSerializer` on an `OutboxedEventMap`. JSON stays the default —
851
+ > see [Protobuf messaging](#protobuf-messaging) and the full guide:
861
852
  > [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
862
853
 
863
854
  ## Producing Events from Outbox to Kafka
@@ -1160,6 +1151,14 @@ class UserJoinedNotificationEvent(cqrs.NotificationEvent[UserJoinedPayload]):
1160
1151
  )
1161
1152
  ```
1162
1153
 
1154
+ JSON remains the default. Opt-in Protobuf wiring (`ProtobufEventSerializer` on an
1155
+ `OutboxedEventMap`, `Message.payload_bytes`, consume via `ProtobufDeserializer`) is
1156
+ documented in the full guide:
1157
+ [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
1158
+ Runnable example:
1159
+ [protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py)
1160
+ (`pip install "python-cqrs[protobuf]"`).
1161
+
1163
1162
  ## Contributing
1164
1163
 
1165
1164
  Contributions are welcome. To develop locally:
@@ -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.14.0"
34
+ version = "4.15.1"
35
35
 
36
36
  [project.optional-dependencies]
37
37
  aiobreaker = ["aiobreaker>=0.3.0"]
@@ -60,6 +60,7 @@ dev = [
60
60
  # Circuit breaker for tests
61
61
  "aiobreaker>=0.3.0", # from aiobreaker
62
62
  "dishka>=1.0", # scoped-deps dishka adapter tests / examples
63
+ "protobuf>=4.25.8", # protobuf codec / deserializer unit tests
63
64
  ]
64
65
  examples = [
65
66
  "fastapi==0.109.*",
@@ -72,6 +73,7 @@ examples = [
72
73
  "dishka>=1.0",
73
74
  ]
74
75
  kafka = ["aiokafka==0.10.0", "retry-async==0.1.*"]
76
+ protobuf = ["protobuf>=4.25.8"]
75
77
  rabbit = ["aio-pika==9.3.0"]
76
78
  sqlalchemy = ["sqlalchemy[asyncio]==2.0.*"]
77
79
  dishka = ["dishka>=1.0"]
@@ -1,3 +1,4 @@
1
+ from cqrs._eol import emit_v4_eol_warning
1
2
  from cqrs.compressors import Compressor, ZlibCompressor
2
3
  from cqrs.container.di import DIContainer
3
4
  from cqrs.container.protocol import Container, SupportsScope
@@ -44,6 +45,13 @@ from cqrs.outbox.sqlalchemy import (
44
45
  SqlAlchemyOutboxedEventRepository,
45
46
  )
46
47
  from cqrs.producer import EventProducer
48
+ from cqrs.deserializers import DeserializeProtobufError, ProtobufDeserializer
49
+ from cqrs.serializers import (
50
+ EventCodec,
51
+ EventSerializer,
52
+ JsonEventSerializer,
53
+ ProtobufEventSerializer,
54
+ )
47
55
  from cqrs.requests.fallback import RequestHandlerFallback
48
56
  from cqrs.requests.map import RequestMap, SagaMap
49
57
  from cqrs.requests.mermaid import CoRMermaid
@@ -111,6 +119,12 @@ __all__ = (
111
119
  "OutboxedEventRepository",
112
120
  "SqlAlchemyOutboxedEventRepository",
113
121
  "EventProducer",
122
+ "EventSerializer",
123
+ "EventCodec",
124
+ "JsonEventSerializer",
125
+ "ProtobufEventSerializer",
126
+ "ProtobufDeserializer",
127
+ "DeserializeProtobufError",
114
128
  "Container",
115
129
  "SupportsScope",
116
130
  "DIContainer",
@@ -136,3 +150,5 @@ __all__ = (
136
150
  "JSONType",
137
151
  "PayloadBinary",
138
152
  )
153
+
154
+ emit_v4_eol_warning()
@@ -0,0 +1,30 @@
1
+ """Package-level EOL notice for the 4.x maintenance line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import warnings
8
+
9
+ _EOL_MESSAGE = (
10
+ "python-cqrs 4.x is in maintenance mode after 5.0.0. "
11
+ "Please migrate to 5.x when ready "
12
+ "(docs: https://mkdocs.python-cqrs.dev/latest/). "
13
+ 'To stay on 4.x: pip install "python-cqrs>=4,<5". '
14
+ "Suppress this optional log with CQRS_SUPPRESS_V4_EOL_WARNING=1 "
15
+ "(DeprecationWarning is still emitted)."
16
+ )
17
+
18
+ _TRUTHY = frozenset({"1", "true", "yes"})
19
+
20
+
21
+ def _suppress_log() -> bool:
22
+ value = os.environ.get("CQRS_SUPPRESS_V4_EOL_WARNING", "")
23
+ return value.strip().lower() in _TRUTHY
24
+
25
+
26
+ def emit_v4_eol_warning() -> None:
27
+ """Emit DeprecationWarning always; optional logger warning unless suppressed."""
28
+ warnings.warn(_EOL_MESSAGE, DeprecationWarning, stacklevel=2)
29
+ if not _suppress_log():
30
+ logging.getLogger("cqrs").warning(_EOL_MESSAGE)
@@ -5,7 +5,7 @@ import ssl
5
5
  import typing
6
6
 
7
7
  from cqrs.adapters import protocol
8
- from cqrs.serializers import default
8
+ from cqrs.serializers.default import passthrough_value_serializer
9
9
 
10
10
  import aiokafka
11
11
  import retry_async
@@ -53,22 +53,41 @@ class KafkaProducer(protocol.KafkaProducer):
53
53
  producer: aiokafka.AIOKafkaProducer,
54
54
  retry_count: int = 3,
55
55
  retry_delay: int = 1,
56
+ legacy_value_serializer: bool = False,
56
57
  ):
57
58
  self._producer = producer
58
59
  self._retry_count = retry_count
59
60
  self._retry_delay = retry_delay
61
+ # True when kafka_producer_factory got an explicit value_serializer so
62
+ # KafkaMessageBroker can pass message.payload (dict) on the non-codec path.
63
+ self.legacy_value_serializer = legacy_value_serializer
60
64
 
61
65
  async def _check_connection(self):
62
66
  node_id = self._producer.client.get_random_node()
63
67
  if not await self._producer.client.ready(node_id=node_id):
64
68
  await self._producer.start()
65
69
 
66
- async def _produce(self, topic: typing.Text, message: typing.Any):
70
+ async def _produce(
71
+ self,
72
+ topic: typing.Text,
73
+ message: typing.Any,
74
+ headers: dict[str, str] | None = None,
75
+ ):
67
76
  await self._check_connection()
68
77
  logger.debug(f"produce message {message} to topic {topic}")
69
- await self._producer.send_and_wait(topic, value=message)
70
-
71
- async def produce(self, topic: typing.Text, message: typing.Any):
78
+ produce_kwargs: dict[str, typing.Any] = {"value": message}
79
+ if headers is not None:
80
+ produce_kwargs["headers"] = [
81
+ (key, value.encode("utf-8") if isinstance(value, str) else value) for key, value in headers.items()
82
+ ]
83
+ await self._producer.send_and_wait(topic, **produce_kwargs)
84
+
85
+ async def produce(
86
+ self,
87
+ topic: typing.Text,
88
+ message: typing.Any,
89
+ headers: dict[str, str] | None = None,
90
+ ):
72
91
  """
73
92
  Produces event to kafka broker.
74
93
  Tries to reconnect if connect has been lost or has not been opened.
@@ -76,6 +95,7 @@ class KafkaProducer(protocol.KafkaProducer):
76
95
  await _retry(tries=self._retry_count, delay=self._retry_delay)(self._produce)(
77
96
  topic,
78
97
  message,
98
+ headers,
79
99
  )
80
100
 
81
101
 
@@ -95,7 +115,7 @@ def kafka_producer_factory(
95
115
 
96
116
  producer = aiokafka.AIOKafkaProducer(
97
117
  bootstrap_servers=dsn,
98
- value_serializer=value_serializer or default.default_serializer,
118
+ value_serializer=passthrough_value_serializer(value_serializer),
99
119
  security_protocol=security_protocol,
100
120
  sasl_mechanism=sasl_mechanism,
101
121
  sasl_plain_username=user,
@@ -107,4 +127,5 @@ def kafka_producer_factory(
107
127
  producer=producer,
108
128
  retry_count=retry_count,
109
129
  retry_delay=retry_delay,
130
+ legacy_value_serializer=value_serializer is not None,
110
131
  )
@@ -5,10 +5,17 @@ if typing.TYPE_CHECKING:
5
5
 
6
6
 
7
7
  class KafkaProducer(typing.Protocol):
8
+ """
9
+ ``legacy_value_serializer`` (optional attribute, default False) is set by
10
+ ``kafka_producer_factory`` when an explicit ``value_serializer`` is passed.
11
+ ``KafkaMessageBroker`` uses it to decide whether to forward ``message.payload``.
12
+ """
13
+
8
14
  async def produce(
9
15
  self,
10
16
  topic: typing.Text,
11
17
  message: typing.Any,
18
+ headers: dict[str, str] | None = None,
12
19
  ) -> None: ...
13
20
 
14
21
 
@@ -0,0 +1,11 @@
1
+ from cqrs.deserializers.exceptions import DeserializeJsonError, DeserializeProtobufError
2
+ from cqrs.deserializers.json import Deserializable, JsonDeserializer
3
+ from cqrs.deserializers.protobuf import ProtobufDeserializer
4
+
5
+ __all__ = (
6
+ "Deserializable",
7
+ "JsonDeserializer",
8
+ "DeserializeJsonError",
9
+ "ProtobufDeserializer",
10
+ "DeserializeProtobufError",
11
+ )
@@ -2,6 +2,22 @@ import dataclasses
2
2
  import typing
3
3
 
4
4
 
5
+ @dataclasses.dataclass(frozen=True)
6
+ class DeserializeProtobufError:
7
+ """
8
+ Error that occurred during protobuf deserialization.
9
+
10
+ Args:
11
+ error_message: Human-readable error message
12
+ error_type: Type of the exception that occurred
13
+ message_data: The original message data that failed to deserialize
14
+ """
15
+
16
+ error_message: str
17
+ error_type: typing.Type[Exception]
18
+ message_data: str | bytes | None
19
+
20
+
5
21
  @dataclasses.dataclass(frozen=True)
6
22
  class DeserializeJsonError:
7
23
  """
@@ -0,0 +1,51 @@
1
+ import logging
2
+ import typing
3
+ import sys
4
+
5
+ from cqrs.deserializers.exceptions import DeserializeProtobufError
6
+
7
+ if sys.version_info >= (3, 11):
8
+ from typing import Self # novm
9
+ else:
10
+ from typing_extensions import Self
11
+
12
+ logger = logging.getLogger("cqrs")
13
+
14
+
15
+ class ProtoDeserializable(typing.Protocol):
16
+ """Objects that can be constructed from a protobuf message via ``from_proto``."""
17
+
18
+ @classmethod
19
+ def from_proto(cls, proto: typing.Any) -> Self: ...
20
+
21
+
22
+ _T = typing.TypeVar("_T", bound=ProtoDeserializable)
23
+
24
+
25
+ class ProtobufDeserializer(typing.Generic[_T]):
26
+ """
27
+ Deserializer for protobuf messages.
28
+
29
+ Converts protobuf wire bytes into Python objects using the ``from_proto``
30
+ classmethod of the target model. Duck-typed: this module does not import
31
+ ``google.protobuf``.
32
+ """
33
+
34
+ def __init__(self, model: type[_T], proto_type: typing.Any) -> None:
35
+ self._model = model
36
+ self._proto_type = proto_type
37
+
38
+ def __call__(self, data: bytes | None) -> _T | None | DeserializeProtobufError:
39
+ if data is None:
40
+ return None
41
+ try:
42
+ return self._model.from_proto(self._proto_type.FromString(data))
43
+ except Exception as e:
44
+ logger.error(
45
+ f"Error while deserializing protobuf message: {e}",
46
+ )
47
+ return DeserializeProtobufError(
48
+ error_message=str(e),
49
+ error_type=type(e),
50
+ message_data=data,
51
+ )
@@ -15,6 +15,9 @@ from cqrs.container.scope import (
15
15
  from cqrs.events.event import IDomainEvent, IEvent, INotificationEvent
16
16
  from cqrs.events import event_handler, map
17
17
  from cqrs.events.fallback import EventHandlerFallback
18
+ from cqrs.serializers.default import headers_for_content_type
19
+ from cqrs.serializers.json import JsonEventSerializer
20
+ from cqrs.serializers.protocol import EventSerializer
18
21
 
19
22
  logger = logging.getLogger("cqrs")
20
23
 
@@ -38,6 +41,8 @@ class EventEmitter:
38
41
  container: di_container.Container,
39
42
  message_broker: message_brokers.MessageBroker | None = None,
40
43
  scope_strategy: ScopeStrategy = ScopeStrategy.NONE,
44
+ *,
45
+ serializer: EventSerializer | None = None,
41
46
  ) -> None:
42
47
  """
43
48
  Initialize the event emitter.
@@ -48,6 +53,7 @@ class EventEmitter:
48
53
  message_broker: Optional broker for notification events; required
49
54
  when emitting :class:`~cqrs.events.event.INotificationEvent`.
50
55
  scope_strategy: DI scope boundary; HANDLER opens a scope per handler.
56
+ serializer: Codec used to fill ``Message.payload_bytes``. Defaults to JSON.
51
57
 
52
58
  Example::
53
59
 
@@ -64,6 +70,7 @@ class EventEmitter:
64
70
  self._container = wrap_container(container)
65
71
  self._message_broker = message_broker
66
72
  self._scope_strategy = scope_strategy
73
+ self._serializer = serializer or JsonEventSerializer()
67
74
 
68
75
  @functools.singledispatchmethod
69
76
  async def emit(self, event: IEvent) -> typing.Sequence[IEvent]:
@@ -108,11 +115,20 @@ class EventEmitter:
108
115
  f"To send event {event}, message broker must be specified.",
109
116
  )
110
117
 
118
+ # payload (dict) and payload_bytes are both intentional: stub brokers /
119
+ # Message.to_dict() need the structured dict; transport uses wire bytes.
120
+ content_type, headers = headers_for_content_type(
121
+ self._serializer.content_type_for(event),
122
+ event,
123
+ )
111
124
  message = message_brokers.Message(
112
125
  message_name=type(event).__name__,
113
126
  message_id=event.event_id,
114
127
  topic=event.topic,
115
128
  payload=event.to_dict(),
129
+ payload_bytes=self._serializer.serialize(event),
130
+ content_type=content_type,
131
+ headers=headers,
116
132
  )
117
133
 
118
134
  logger.debug(
@@ -1,10 +1,10 @@
1
1
  import logging
2
2
 
3
3
  import aio_pika
4
- import orjson
5
4
 
6
5
  from cqrs.adapters import protocol as adapters_protocol
7
6
  from cqrs.message_brokers import protocol
7
+ from cqrs.serializers.default import message_wire_bytes
8
8
 
9
9
 
10
10
  class AMQPMessageBroker(protocol.MessageBroker):
@@ -20,8 +20,16 @@ class AMQPMessageBroker(protocol.MessageBroker):
20
20
  logging.getLogger("aio_pika").setLevel(pika_log_level)
21
21
 
22
22
  async def send_message(self, message: protocol.Message) -> None:
23
+ kwargs: dict = {}
24
+ if message.content_type is not None:
25
+ kwargs["content_type"] = message.content_type
26
+ if message.headers is not None:
27
+ kwargs["headers"] = message.headers
23
28
  await self.publisher.publish(
24
- message=aio_pika.Message(body=orjson.dumps(message.payload)),
29
+ message=aio_pika.Message(
30
+ body=message_wire_bytes(message),
31
+ **kwargs,
32
+ ),
25
33
  queue_name=message.topic,
26
34
  exchange_name=self.exchange_name,
27
35
  )
@@ -0,0 +1,45 @@
1
+ import logging
2
+ import typing
3
+
4
+ from cqrs.adapters import protocol as adapters_protocol
5
+ from cqrs.message_brokers import protocol
6
+ from cqrs.serializers.default import message_wire_bytes
7
+
8
+
9
+ class KafkaMessageBroker(protocol.MessageBroker):
10
+ def __init__(
11
+ self,
12
+ producer: adapters_protocol.KafkaProducer,
13
+ aiokafka_log_level: typing.Text = "ERROR",
14
+ ):
15
+ self._producer = producer
16
+ logging.getLogger("aiokafka").setLevel(aiokafka_log_level)
17
+
18
+ def _produce_value(self, message: protocol.Message) -> typing.Any:
19
+ """
20
+ Choose the value passed to ``KafkaProducer.produce``.
21
+
22
+ When a legacy ``value_serializer`` was configured on the producer and the
23
+ message has no codec wire bytes, forward ``message.payload`` so that
24
+ serializer can run. Otherwise prefer ``payload_bytes`` / encoded payload
25
+ so Outbox and EventSerializer bytes are not discarded.
26
+ """
27
+ legacy_value_serializer = getattr(
28
+ self._producer,
29
+ "legacy_value_serializer",
30
+ False,
31
+ )
32
+ if legacy_value_serializer and message.payload_bytes is None and message.content_type is None:
33
+ return message.payload
34
+ return message_wire_bytes(message)
35
+
36
+ async def send_message(self, message: protocol.Message) -> None:
37
+ payload = self._produce_value(message)
38
+ if message.headers is None:
39
+ await self._producer.produce(message.topic, payload)
40
+ else:
41
+ await self._producer.produce(
42
+ message.topic,
43
+ payload,
44
+ headers=message.headers,
45
+ )
@@ -0,0 +1,74 @@
1
+ import abc
2
+ import base64
3
+ import dataclasses
4
+ import typing
5
+ import uuid
6
+ from dataclass_wizard import asdict
7
+
8
+
9
+ @dataclasses.dataclass
10
+ class Message:
11
+ """
12
+ Internal message structure for message broker communication.
13
+
14
+ Args:
15
+ message_name: Name of the message type
16
+ message_id: Unique identifier for the message (auto-generated if not provided)
17
+ topic: Message broker topic where the message should be sent
18
+ payload: Structured message payload (typically ``event.to_dict()``)
19
+ payload_bytes: Optional codec wire-bytes for the transport
20
+ content_type: Optional MIME type (set only by typed codecs such as Protobuf)
21
+ headers: Optional broker headers (set only when ``content_type`` is set)
22
+ """
23
+
24
+ message_name: typing.Text
25
+ topic: typing.Text
26
+ payload: typing.Any
27
+ message_id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4)
28
+ payload_bytes: bytes | None = None
29
+ content_type: str | None = None
30
+ headers: dict[str, str] | None = None
31
+
32
+ def to_dict(self) -> dict[str, typing.Any]:
33
+ """
34
+ Convert the message instance to a dictionary representation.
35
+
36
+ Core fields use ``dataclass_wizard.asdict`` camelCase (``messageName``,
37
+ ``messageId``, …) for JSON-path ``GET /published`` stability. Codec
38
+ extras (``content_type`` / ``payload_bytes`` / ``headers``) are added
39
+ only when ``content_type`` is set, in snake_case.
40
+ """
41
+ # Rebuild with only the original fields so asdict matches the historical
42
+ # JSON-path shape (camelCase messageName/messageId). Extra codec fields
43
+ # still appear as None camelCase keys from defaults — drop them.
44
+ data = asdict(
45
+ Message(
46
+ message_name=self.message_name,
47
+ topic=self.topic,
48
+ payload=self.payload,
49
+ message_id=self.message_id,
50
+ ),
51
+ )
52
+ data.pop("payloadBytes", None)
53
+ data.pop("contentType", None)
54
+ data.pop("headers", None)
55
+ if self.content_type is not None:
56
+ data["content_type"] = self.content_type
57
+ if self.payload_bytes is not None:
58
+ data["payload_bytes"] = base64.b64encode(self.payload_bytes).decode("ascii")
59
+ data["payload_bytes_encoding"] = "base64"
60
+ if self.headers is not None:
61
+ data["headers"] = self.headers
62
+ return data
63
+
64
+
65
+ class MessageBroker(abc.ABC):
66
+ """
67
+ The interface over a message broker.
68
+
69
+ Used for sending messages to message brokers (currently only redis supported).
70
+ """
71
+
72
+ @abc.abstractmethod
73
+ async def send_message(self, message: Message) -> None:
74
+ raise NotImplementedError