python-cqrs 4.14.0__tar.gz → 4.15.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.
Files changed (101) hide show
  1. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/PKG-INFO +15 -7
  2. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/README.md +11 -6
  3. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/pyproject.toml +3 -1
  4. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/__init__.py +13 -0
  5. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/adapters/kafka.py +27 -6
  6. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/adapters/protocol.py +7 -0
  7. python_cqrs-4.15.0/src/cqrs/deserializers/__init__.py +11 -0
  8. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/deserializers/exceptions.py +16 -0
  9. python_cqrs-4.15.0/src/cqrs/deserializers/protobuf.py +51 -0
  10. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/event_emitter.py +16 -0
  11. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/message_brokers/amqp.py +10 -2
  12. python_cqrs-4.15.0/src/cqrs/message_brokers/kafka.py +45 -0
  13. python_cqrs-4.15.0/src/cqrs/message_brokers/protocol.py +74 -0
  14. python_cqrs-4.15.0/src/cqrs/outbox/map.py +103 -0
  15. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/outbox/mock.py +18 -2
  16. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/outbox/repository.py +4 -0
  17. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/outbox/sqlalchemy.py +39 -11
  18. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/producer.py +8 -0
  19. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/bootstrap.py +22 -0
  20. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/bootstrap.py +8 -0
  21. python_cqrs-4.15.0/src/cqrs/serializers/__init__.py +12 -0
  22. python_cqrs-4.15.0/src/cqrs/serializers/default.py +86 -0
  23. python_cqrs-4.15.0/src/cqrs/serializers/json.py +22 -0
  24. python_cqrs-4.15.0/src/cqrs/serializers/protobuf.py +48 -0
  25. python_cqrs-4.15.0/src/cqrs/serializers/protocol.py +21 -0
  26. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/python_cqrs.egg-info/PKG-INFO +15 -7
  27. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/python_cqrs.egg-info/SOURCES.txt +4 -0
  28. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/python_cqrs.egg-info/requires.txt +4 -0
  29. python_cqrs-4.14.0/src/cqrs/deserializers/__init__.py +0 -8
  30. python_cqrs-4.14.0/src/cqrs/message_brokers/kafka.py +0 -18
  31. python_cqrs-4.14.0/src/cqrs/message_brokers/protocol.py +0 -44
  32. python_cqrs-4.14.0/src/cqrs/outbox/map.py +0 -24
  33. python_cqrs-4.14.0/src/cqrs/serializers/__init__.py +0 -3
  34. python_cqrs-4.14.0/src/cqrs/serializers/default.py +0 -24
  35. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/LICENSE +0 -0
  36. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/setup.cfg +0 -0
  37. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/adapters/__init__.py +0 -0
  38. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/adapters/amqp.py +0 -0
  39. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
  40. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/circuit_breaker.py +0 -0
  41. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/compressors/__init__.py +0 -0
  42. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/compressors/protocol.py +0 -0
  43. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/compressors/zlib.py +0 -0
  44. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/__init__.py +0 -0
  45. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/dependency_injector.py +0 -0
  46. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/di.py +0 -0
  47. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/dishka.py +0 -0
  48. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/protocol.py +0 -0
  49. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/container/scope.py +0 -0
  50. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/deserializers/json.py +0 -0
  51. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/__init__.py +0 -0
  52. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/event.py +0 -0
  53. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/exceptions.py +0 -0
  54. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/models.py +0 -0
  55. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/request.py +0 -0
  56. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/saga.py +0 -0
  57. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/dispatcher/streaming.py +0 -0
  58. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/__init__.py +0 -0
  59. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/bootstrap.py +0 -0
  60. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/event.py +0 -0
  61. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/event_handler.py +0 -0
  62. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/event_processor.py +0 -0
  63. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/fallback.py +0 -0
  64. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/events/map.py +0 -0
  65. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/generic_utils.py +0 -0
  66. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/mediator.py +0 -0
  67. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/message_brokers/__init__.py +0 -0
  68. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/message_brokers/devnull.py +0 -0
  69. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/middlewares/__init__.py +0 -0
  70. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/middlewares/base.py +0 -0
  71. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/middlewares/logging.py +0 -0
  72. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/outbox/__init__.py +0 -0
  73. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/__init__.py +0 -0
  74. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/cor_request_handler.py +0 -0
  75. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/fallback.py +0 -0
  76. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/map.py +0 -0
  77. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/mermaid.py +0 -0
  78. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/request.py +0 -0
  79. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/requests/request_handler.py +0 -0
  80. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/response.py +0 -0
  81. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/__init__.py +0 -0
  82. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/circuit_breaker.py +0 -0
  83. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/compensation.py +0 -0
  84. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/execution.py +0 -0
  85. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/fallback.py +0 -0
  86. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/mermaid.py +0 -0
  87. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/models.py +0 -0
  88. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/recovery.py +0 -0
  89. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/saga.py +0 -0
  90. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/step.py +0 -0
  91. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/__init__.py +0 -0
  92. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/enums.py +0 -0
  93. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/memory.py +0 -0
  94. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/models.py +0 -0
  95. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/protocol.py +0 -0
  96. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/storage/sqlalchemy.py +0 -0
  97. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/saga/validation.py +0 -0
  98. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/sqlalchemy_types.py +0 -0
  99. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/cqrs/types.py +0 -0
  100. {python_cqrs-4.14.0 → python_cqrs-4.15.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  101. {python_cqrs-4.14.0 → python_cqrs-4.15.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.14.0
3
+ Version: 4.15.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>
@@ -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
@@ -923,12 +926,9 @@ A runnable FastAPI flow (route → command → transactional outbox → publishe
923
926
  > [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
924
927
  > how to register a native type for your own database.
925
928
  > [!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:
929
+ > For Protobuf events, implement `proto()` / `from_proto()` and register a
930
+ > `ProtobufEventSerializer` on an `OutboxedEventMap`. JSON stays the default —
931
+ > see [Protobuf messaging](#protobuf-messaging) and the full guide:
932
932
  > [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
933
933
 
934
934
  ## Producing Events from Outbox to Kafka
@@ -1231,6 +1231,14 @@ class UserJoinedNotificationEvent(cqrs.NotificationEvent[UserJoinedPayload]):
1231
1231
  )
1232
1232
  ```
1233
1233
 
1234
+ JSON remains the default. Opt-in Protobuf wiring (`ProtobufEventSerializer` on an
1235
+ `OutboxedEventMap`, `Message.payload_bytes`, consume via `ProtobufDeserializer`) is
1236
+ documented in the full guide:
1237
+ [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
1238
+ Runnable example:
1239
+ [protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py)
1240
+ (`pip install "python-cqrs[protobuf]"`).
1241
+
1234
1242
  ## Contributing
1235
1243
 
1236
1244
  Contributions are welcome. To develop locally:
@@ -852,12 +852,9 @@ A runnable FastAPI flow (route → command → transactional outbox → publishe
852
852
  > [Database Support](https://mkdocs.python-cqrs.dev/outbox/databases/) docs for the DDL, the Alembic recipe and
853
853
  > how to register a native type for your own database.
854
854
  > [!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:
855
+ > For Protobuf events, implement `proto()` / `from_proto()` and register a
856
+ > `ProtobufEventSerializer` on an `OutboxedEventMap`. JSON stays the default —
857
+ > see [Protobuf messaging](#protobuf-messaging) and the full guide:
861
858
  > [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
862
859
 
863
860
  ## Producing Events from Outbox to Kafka
@@ -1160,6 +1157,14 @@ class UserJoinedNotificationEvent(cqrs.NotificationEvent[UserJoinedPayload]):
1160
1157
  )
1161
1158
  ```
1162
1159
 
1160
+ JSON remains the default. Opt-in Protobuf wiring (`ProtobufEventSerializer` on an
1161
+ `OutboxedEventMap`, `Message.payload_bytes`, consume via `ProtobufDeserializer`) is
1162
+ documented in the full guide:
1163
+ [Protobuf Integration](https://mkdocs.python-cqrs.dev/protobuf/).
1164
+ Runnable example:
1165
+ [protobuf_outbox.py](https://github.com/vadikko2/python-cqrs/blob/master/examples/outbox/protobuf_outbox.py)
1166
+ (`pip install "python-cqrs[protobuf]"`).
1167
+
1163
1168
  ## Contributing
1164
1169
 
1165
1170
  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.0"
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"]
@@ -44,6 +44,13 @@ from cqrs.outbox.sqlalchemy import (
44
44
  SqlAlchemyOutboxedEventRepository,
45
45
  )
46
46
  from cqrs.producer import EventProducer
47
+ from cqrs.deserializers import DeserializeProtobufError, ProtobufDeserializer
48
+ from cqrs.serializers import (
49
+ EventCodec,
50
+ EventSerializer,
51
+ JsonEventSerializer,
52
+ ProtobufEventSerializer,
53
+ )
47
54
  from cqrs.requests.fallback import RequestHandlerFallback
48
55
  from cqrs.requests.map import RequestMap, SagaMap
49
56
  from cqrs.requests.mermaid import CoRMermaid
@@ -111,6 +118,12 @@ __all__ = (
111
118
  "OutboxedEventRepository",
112
119
  "SqlAlchemyOutboxedEventRepository",
113
120
  "EventProducer",
121
+ "EventSerializer",
122
+ "EventCodec",
123
+ "JsonEventSerializer",
124
+ "ProtobufEventSerializer",
125
+ "ProtobufDeserializer",
126
+ "DeserializeProtobufError",
114
127
  "Container",
115
128
  "SupportsScope",
116
129
  "DIContainer",
@@ -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
@@ -0,0 +1,103 @@
1
+ import functools
2
+ import typing
3
+
4
+ from cqrs.events.event import INotificationEvent
5
+ from cqrs.serializers.json import JsonEventSerializer
6
+ from cqrs.serializers.protocol import EventCodec, EventSerializer
7
+
8
+
9
+ class _bind_self_or_singleton:
10
+ """
11
+ Descriptor for dual class/instance API.
12
+
13
+ Class access binds to the process-global singleton; instance access binds to
14
+ that map instance.
15
+ """
16
+
17
+ def __init__(self, func: typing.Callable) -> None:
18
+ self.func = func
19
+
20
+ def __get__(
21
+ self,
22
+ obj: typing.Any,
23
+ objtype: type | None = None,
24
+ ) -> typing.Callable:
25
+ target = obj if obj is not None else objtype._singleton() # type: ignore[union-attr]
26
+ return functools.partial(self.func, target)
27
+
28
+
29
+ class OutboxedEventMap:
30
+ """
31
+ Registry of outbox event names to types and optional codecs.
32
+
33
+ Class-level ``register`` / ``get`` use a process-global singleton (existing
34
+ behaviour). ``OutboxedEventMap()`` creates an isolated registry.
35
+
36
+ Warning: class-level methods mutate the process singleton; prefer an
37
+ instance map when tests or apps need isolation.
38
+ """
39
+
40
+ _instance: typing.ClassVar["OutboxedEventMap | None"] = None
41
+
42
+ def __init__(self) -> None:
43
+ self._entries: typing.Dict[
44
+ typing.Text,
45
+ typing.Tuple[typing.Type[INotificationEvent], EventCodec | None],
46
+ ] = {}
47
+
48
+ @classmethod
49
+ def _singleton(cls) -> "OutboxedEventMap":
50
+ if cls._instance is None:
51
+ cls._instance = cls()
52
+ return cls._instance
53
+
54
+ @_bind_self_or_singleton
55
+ def register(
56
+ self,
57
+ event_name: typing.Text,
58
+ event_type: typing.Type[INotificationEvent],
59
+ serializer: EventCodec | None = None,
60
+ ) -> None:
61
+ if event_name in self._entries:
62
+ raise KeyError(f"Event with {event_name} already registered")
63
+ self._entries[event_name] = (event_type, serializer)
64
+
65
+ @_bind_self_or_singleton
66
+ def get(
67
+ self,
68
+ event_name: typing.Text,
69
+ ) -> typing.Type[INotificationEvent] | None:
70
+ entry = self._entries.get(event_name)
71
+ if entry is None:
72
+ return None
73
+ return entry[0]
74
+
75
+ @_bind_self_or_singleton
76
+ def get_serializer(
77
+ self,
78
+ event_name: typing.Text,
79
+ ) -> EventCodec | None:
80
+ entry = self._entries.get(event_name)
81
+ if entry is None:
82
+ return None
83
+ return entry[1]
84
+
85
+ @_bind_self_or_singleton
86
+ def as_serializer(
87
+ self,
88
+ default: EventSerializer | None = None,
89
+ ) -> EventSerializer:
90
+ fallback = default or JsonEventSerializer()
91
+ event_map = self
92
+
93
+ class _Dispatcher:
94
+ def resolve_codec(self, event: INotificationEvent) -> EventSerializer:
95
+ return event_map.get_serializer(event.event_name) or fallback
96
+
97
+ def serialize(self, event: INotificationEvent) -> bytes:
98
+ return self.resolve_codec(event).serialize(event)
99
+
100
+ def content_type_for(self, event: INotificationEvent) -> str | None:
101
+ return self.resolve_codec(event).content_type_for(event)
102
+
103
+ return _Dispatcher()
@@ -1,14 +1,24 @@
1
1
  import typing
2
2
 
3
3
  import cqrs
4
- from cqrs.outbox import repository
4
+ from cqrs.outbox import map, repository
5
+ from cqrs.serializers.json import JsonEventSerializer
6
+ from cqrs.serializers.protocol import EventCodec
5
7
 
6
8
 
7
9
  class MockOutboxedEventRepository(repository.OutboxedEventRepository):
8
10
  COUNTER: typing.ClassVar = 0
9
11
 
10
- def __init__(self, session_factory: typing.Callable[[], typing.Dict]):
12
+ def __init__(
13
+ self,
14
+ session_factory: typing.Callable[[], typing.Dict],
15
+ *,
16
+ serializer: EventCodec | None = None,
17
+ event_map: map.OutboxedEventMap | None = None,
18
+ ):
11
19
  self.session = session_factory()
20
+ self._serializer = serializer or JsonEventSerializer()
21
+ self._event_map = event_map if event_map is not None else map.OutboxedEventMap
12
22
 
13
23
  async def __aenter__(self) -> typing.Dict:
14
24
  return self.session
@@ -16,13 +26,19 @@ class MockOutboxedEventRepository(repository.OutboxedEventRepository):
16
26
  async def __aexit__(self, exc_type, exc_val, exc_tb):
17
27
  pass
18
28
 
29
+ def _resolve_codec(self, event_name: str) -> EventCodec:
30
+ return self._event_map.get_serializer(event_name) or self._serializer
31
+
19
32
  def add(self, event: cqrs.INotificationEvent) -> None:
20
33
  MockOutboxedEventRepository.COUNTER += 1
34
+ codec = self._resolve_codec(event.event_name)
21
35
  self.session[MockOutboxedEventRepository.COUNTER] = repository.OutboxedEvent(
22
36
  id=MockOutboxedEventRepository.COUNTER,
23
37
  event=event,
24
38
  topic=event.topic,
25
39
  status=repository.EventStatus.NEW,
40
+ payload_bytes=codec.serialize(event),
41
+ content_type=codec.content_type_for(event),
26
42
  )
27
43
 
28
44
  async def get_many(