python-cqrs 5.2.0__tar.gz → 5.3.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 (118) hide show
  1. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/PKG-INFO +2 -1
  2. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/pyproject.toml +2 -1
  3. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/__init__.py +33 -0
  4. python_cqrs-5.3.0/src/cqrs/inbox/__init__.py +21 -0
  5. python_cqrs-5.3.0/src/cqrs/inbox/map.py +201 -0
  6. python_cqrs-5.3.0/src/cqrs/inbox/memory.py +110 -0
  7. python_cqrs-5.3.0/src/cqrs/inbox/processor.py +116 -0
  8. python_cqrs-5.3.0/src/cqrs/inbox/repository.py +149 -0
  9. python_cqrs-5.3.0/src/cqrs/inbox/sqlalchemy.py +322 -0
  10. python_cqrs-5.3.0/src/cqrs/inbox/sqlmodel.py +164 -0
  11. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/PKG-INFO +2 -1
  12. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/SOURCES.txt +7 -0
  13. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/LICENSE +0 -0
  14. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/README.md +0 -0
  15. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/setup.cfg +0 -0
  16. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/_dataclass_utils.py +0 -0
  17. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/__init__.py +0 -0
  18. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/amqp.py +0 -0
  19. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
  20. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/kafka.py +0 -0
  21. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/protocol.py +0 -0
  22. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/__init__.py +0 -0
  23. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/events.py +0 -0
  24. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/requests.py +0 -0
  25. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/saga.py +0 -0
  26. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/circuit_breaker.py +0 -0
  27. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/__init__.py +0 -0
  28. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/protocol.py +0 -0
  29. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/zlib.py +0 -0
  30. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/__init__.py +0 -0
  31. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/dependency_injector.py +0 -0
  32. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/di.py +0 -0
  33. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/dishka.py +0 -0
  34. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/protocol.py +0 -0
  35. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/scope.py +0 -0
  36. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/__init__.py +0 -0
  37. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/exceptions.py +0 -0
  38. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/json.py +0 -0
  39. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/protobuf.py +0 -0
  40. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/__init__.py +0 -0
  41. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/event.py +0 -0
  42. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/exceptions.py +0 -0
  43. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/models.py +0 -0
  44. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/request.py +0 -0
  45. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/saga.py +0 -0
  46. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/streaming.py +0 -0
  47. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/__init__.py +0 -0
  48. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/event_emitter.py +0 -0
  49. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/event_processor.py +0 -0
  50. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/fallback.py +0 -0
  51. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/generic_utils.py +0 -0
  52. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/__init__.py +0 -0
  53. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/cor.py +0 -0
  54. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/event.py +0 -0
  55. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/request.py +0 -0
  56. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/saga.py +0 -0
  57. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/__init__.py +0 -0
  58. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/events.py +0 -0
  59. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/outbox.py +0 -0
  60. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/requests.py +0 -0
  61. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/__init__.py +0 -0
  62. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/_common.py +0 -0
  63. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/event.py +0 -0
  64. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/request.py +0 -0
  65. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/saga.py +0 -0
  66. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/streaming.py +0 -0
  67. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/__init__.py +0 -0
  68. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/cor.py +0 -0
  69. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/saga.py +0 -0
  70. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/__init__.py +0 -0
  71. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/amqp.py +0 -0
  72. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/devnull.py +0 -0
  73. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/kafka.py +0 -0
  74. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/producer.py +0 -0
  75. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/protocol.py +0 -0
  76. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/__init__.py +0 -0
  77. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/base.py +0 -0
  78. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/logging.py +0 -0
  79. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/__init__.py +0 -0
  80. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/event.py +0 -0
  81. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/pydantic.py +0 -0
  82. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/request.py +0 -0
  83. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/response.py +0 -0
  84. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/__init__.py +0 -0
  85. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/mock.py +0 -0
  86. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/repository.py +0 -0
  87. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/sqlalchemy.py +0 -0
  88. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/sqlmodel.py +0 -0
  89. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/py.typed +0 -0
  90. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/requests/__init__.py +0 -0
  91. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/requests/fallback.py +0 -0
  92. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/__init__.py +0 -0
  93. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/circuit_breaker.py +0 -0
  94. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/compensation.py +0 -0
  95. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/execution.py +0 -0
  96. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/fallback.py +0 -0
  97. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/models.py +0 -0
  98. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/recovery.py +0 -0
  99. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/saga.py +0 -0
  100. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/__init__.py +0 -0
  101. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/enums.py +0 -0
  102. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/memory.py +0 -0
  103. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/models.py +0 -0
  104. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/protocol.py +0 -0
  105. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/sqlalchemy.py +0 -0
  106. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/sqlmodel.py +0 -0
  107. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/validation.py +0 -0
  108. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/__init__.py +0 -0
  109. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/default.py +0 -0
  110. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/json.py +0 -0
  111. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/protobuf.py +0 -0
  112. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/protocol.py +0 -0
  113. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/sqlalchemy_types.py +0 -0
  114. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/sqlmodel_base.py +0 -0
  115. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/types.py +0 -0
  116. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  117. {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/requires.txt +0 -0
  118. {python_cqrs-5.2.0 → python_cqrs-5.3.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: 5.2.0
3
+ Version: 5.3.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>
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
17
18
  Requires-Python: >=3.10
18
19
  Description-Content-Type: text/markdown
19
20
  License-File: LICENSE
@@ -15,6 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.11",
16
16
  "Programming Language :: Python :: 3.12",
17
17
  "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
18
19
  ]
19
20
  dependencies = [
20
21
  "dataclass-wizard==0.*",
@@ -29,7 +30,7 @@ maintainers = [{ name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru" }]
29
30
  name = "python-cqrs"
30
31
  readme = "README.md"
31
32
  requires-python = ">=3.10"
32
- version = "5.2.0"
33
+ version = "5.3.0"
33
34
 
34
35
  [project.optional-dependencies]
35
36
  aiobreaker = ["aiobreaker>=0.3.0"]
@@ -46,6 +46,14 @@ Outbox on ``cqrs``: ``OutboxedEventRepository``, ``OutboxedEventMap``,
46
46
  the ``sqlmodel`` extra. Codecs on ``cqrs``: ``JsonEventSerializer``,
47
47
  ``ProtobufEventSerializer`` (extra ``protobuf``).
48
48
 
49
+ Inbox on ``cqrs``: ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
50
+ ``InboxMessageMap``, ``InboxProcessor``. ``SqlAlchemyInboxRepository`` and
51
+ ``rebind_inbox_model`` are lazy and need the ``sqlalchemy`` extra.
52
+ ``SqlModelInboxRepository`` is lazy and needs the ``sqlmodel`` extra.
53
+ Presentation calls ``add`` with a batch. ``InboxProcessor`` reads rows and
54
+ calls the mediator that is already registered. FastAPI and FastStream are
55
+ not imported here.
56
+
49
57
  Brokers are not on ``cqrs``. Kafka is
50
58
  ``cqrs.adapters.kafka.kafka_producer_factory`` plus
51
59
  ``cqrs.message_brokers.kafka.KafkaMessageBroker`` (extra ``kafka``). AMQP is
@@ -79,6 +87,11 @@ if typing.TYPE_CHECKING:
79
87
  )
80
88
  from cqrs.models.pydantic import PydanticRequest
81
89
  from cqrs.models.pydantic import PydanticResponse
90
+ from cqrs.inbox.sqlalchemy import (
91
+ SqlAlchemyInboxRepository,
92
+ rebind_inbox_model,
93
+ )
94
+ from cqrs.inbox.sqlmodel import SqlModelInboxRepository
82
95
  from cqrs.outbox.sqlalchemy import (
83
96
  SqlAlchemyOutboxedEventRepository,
84
97
  rebind_outbox_model,
@@ -126,6 +139,9 @@ from cqrs.mediators import (
126
139
  SagaMediator,
127
140
  StreamingRequestMediator,
128
141
  )
142
+ from cqrs.inbox.map import InboxMessageMap
143
+ from cqrs.inbox.processor import InboxProcessor
144
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
129
145
  from cqrs.mapping.outbox import OutboxedEventMap
130
146
  from cqrs.outbox.repository import (
131
147
  EventStatus,
@@ -202,6 +218,13 @@ __all__ = (
202
218
  "OutboxedEventRepository",
203
219
  "SqlAlchemyOutboxedEventRepository",
204
220
  "SqlModelOutboxedEventRepository",
221
+ "InboxRepository",
222
+ "InboxMessage",
223
+ "InboxStatus",
224
+ "InboxMessageMap",
225
+ "InboxProcessor",
226
+ "SqlAlchemyInboxRepository",
227
+ "SqlModelInboxRepository",
205
228
  "EventProducer",
206
229
  "EventSerializer",
207
230
  "EventCodec",
@@ -220,6 +243,7 @@ __all__ = (
220
243
  "Compressor",
221
244
  "ZlibCompressor",
222
245
  "rebind_outbox_model",
246
+ "rebind_inbox_model",
223
247
  "Saga",
224
248
  "SagaStepHandler",
225
249
  "SagaStepResult",
@@ -252,6 +276,11 @@ _LAZY_SQLALCHEMY = {
252
276
  "SqlAlchemyOutboxedEventRepository",
253
277
  ),
254
278
  "rebind_outbox_model": ("cqrs.outbox.sqlalchemy", "rebind_outbox_model"),
279
+ "SqlAlchemyInboxRepository": (
280
+ "cqrs.inbox.sqlalchemy",
281
+ "SqlAlchemyInboxRepository",
282
+ ),
283
+ "rebind_inbox_model": ("cqrs.inbox.sqlalchemy", "rebind_inbox_model"),
255
284
  "DialectAwareType": ("cqrs.sqlalchemy_types", "DialectAwareType"),
256
285
  "DialectTypeHandler": ("cqrs.sqlalchemy_types", "DialectTypeHandler"),
257
286
  "UUIDBinary": ("cqrs.sqlalchemy_types", "UUIDBinary"),
@@ -265,6 +294,10 @@ _LAZY_SQLMODEL = {
265
294
  "cqrs.outbox.sqlmodel",
266
295
  "SqlModelOutboxedEventRepository",
267
296
  ),
297
+ "SqlModelInboxRepository": (
298
+ "cqrs.inbox.sqlmodel",
299
+ "SqlModelInboxRepository",
300
+ ),
268
301
  }
269
302
 
270
303
 
@@ -0,0 +1,21 @@
1
+ """
2
+ Transactional inbox.
3
+
4
+ [AI Instruction]
5
+ Import ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
6
+ ``InboxMessageMap``, and ``InboxProcessor`` from ``cqrs``. SQLAlchemy and
7
+ SQLModel repositories are lazy. Presentation calls ``add``. A worker calls
8
+ ``InboxProcessor``.
9
+ """
10
+
11
+ from cqrs.inbox.map import InboxMessageMap
12
+ from cqrs.inbox.processor import InboxProcessor
13
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
14
+
15
+ __all__ = (
16
+ "InboxMessage",
17
+ "InboxMessageMap",
18
+ "InboxProcessor",
19
+ "InboxRepository",
20
+ "InboxStatus",
21
+ )
@@ -0,0 +1,201 @@
1
+ """
2
+ Name registry for inbox messages.
3
+
4
+ [AI Instruction]
5
+ Register each request or event name before ``InboxRepository.add``. JSON is
6
+ the default codec. A second ``register`` of the same name raises ``KeyError``.
7
+ Prefer an instance when tests must not share the process-global map.
8
+ """
9
+
10
+ import functools
11
+ import typing
12
+
13
+ import orjson
14
+
15
+ from cqrs.inbox.repository import InboxEntry
16
+ from cqrs.models.event import IEvent
17
+ from cqrs.models.request import IRequest
18
+
19
+ _KT = typing.TypeVar("_KT")
20
+
21
+
22
+ class _JsonMessageSerializer:
23
+ """JSON codec. Wire format is ``orjson.dumps(message.to_dict())``."""
24
+
25
+ def serialize(self, message: InboxEntry) -> bytes:
26
+ return orjson.dumps(message.to_dict())
27
+
28
+ def deserialize(self, payload: bytes, message_type: typing.Type[_KT]) -> _KT:
29
+ data = orjson.loads(payload)
30
+ if not isinstance(data, dict):
31
+ raise TypeError("inbox payload must be a JSON object")
32
+ return message_type.from_dict(**data) # type: ignore[attr-defined]
33
+
34
+
35
+ class MessageCodec(typing.Protocol):
36
+ """Serialize and deserialize one inbox message type."""
37
+
38
+ def serialize(self, message: InboxEntry) -> bytes: ...
39
+
40
+ def deserialize(self, payload: bytes, message_type: typing.Type[typing.Any]) -> typing.Any: ...
41
+
42
+
43
+ class _bind_self_or_singleton:
44
+ """Class access binds the process singleton; instance access binds that map."""
45
+
46
+ def __init__(self, func: typing.Callable) -> None:
47
+ self.func = func
48
+
49
+ def __get__(
50
+ self,
51
+ obj: typing.Any,
52
+ objtype: type | None = None,
53
+ ) -> typing.Callable:
54
+ target = obj if obj is not None else objtype._singleton() # type: ignore[union-attr]
55
+ return functools.partial(self.func, target)
56
+
57
+
58
+ def message_name(message: InboxEntry) -> str:
59
+ """
60
+ Name stored with the row.
61
+
62
+ Notification events use ``event_name``, the same string the outbox stores.
63
+ Requests and domain events use the class name.
64
+ """
65
+
66
+ event_name = getattr(message, "event_name", None)
67
+ if isinstance(event_name, str) and event_name:
68
+ return event_name
69
+ return type(message).__name__
70
+
71
+
72
+ def _type_matches(message_type: type, registered: typing.Any) -> bool:
73
+ registered_origin = typing.get_origin(registered) or registered
74
+ if message_type is registered:
75
+ return True
76
+ if typing.get_origin(registered) is not None and message_type is registered_origin:
77
+ return True
78
+ if isinstance(registered, type) and issubclass(message_type, registered):
79
+ return True
80
+ return False
81
+
82
+
83
+ class InboxMessageMap:
84
+ """
85
+ Registry of inbox message names to request or event types.
86
+
87
+ Class-level ``register`` / ``get`` use a process-global singleton.
88
+ ``InboxMessageMap()`` creates an isolated registry.
89
+
90
+ [AI Instruction]
91
+ Tell the inbox which Python type belongs to a message name. Register
92
+ before the repository reads or writes that message. JSON is the default.
93
+ A second registration of the same name raises ``KeyError``. Requests and
94
+ domain events are registered under the class name. Notification events
95
+ are registered under ``event_name``.
96
+ """
97
+
98
+ _instance: typing.ClassVar["InboxMessageMap | None"] = None
99
+
100
+ def __init__(self) -> None:
101
+ self._entries: typing.Dict[str, typing.Tuple[typing.Any, MessageCodec | None]] = {}
102
+
103
+ @classmethod
104
+ def _singleton(cls) -> "InboxMessageMap":
105
+ if cls._instance is None:
106
+ cls._instance = cls()
107
+ return cls._instance
108
+
109
+ @_bind_self_or_singleton
110
+ def register(
111
+ self,
112
+ message_name_: str,
113
+ message_type: typing.Type[InboxEntry],
114
+ serializer: MessageCodec | None = None,
115
+ ) -> None:
116
+ """
117
+ Bind ``message_name_`` to a request or event class.
118
+
119
+ [AI Instruction]
120
+ Call once at startup. A second registration of the same name raises
121
+ ``KeyError``. Omit ``serializer`` for JSON.
122
+ """
123
+
124
+ if message_name_ in self._entries:
125
+ raise KeyError(f"Message {message_name_} already registered")
126
+ self._entries[message_name_] = (message_type, serializer)
127
+
128
+ @_bind_self_or_singleton
129
+ def get(self, message_name_: str) -> typing.Any | None:
130
+ """Return the registered type, or ``None`` when the name is unknown."""
131
+
132
+ entry = self._entries.get(message_name_)
133
+ if entry is None:
134
+ return None
135
+ return entry[0]
136
+
137
+ @_bind_self_or_singleton
138
+ def get_serializer(self, message_name_: str) -> MessageCodec | None:
139
+ """Return the codec registered for this name, or ``None`` for JSON."""
140
+
141
+ entry = self._entries.get(message_name_)
142
+ if entry is None:
143
+ return None
144
+ return entry[1]
145
+
146
+ @_bind_self_or_singleton
147
+ def deserialize(self, message_name_: str, payload: bytes) -> InboxEntry:
148
+ """
149
+ Rebuild a request or event from stored bytes.
150
+
151
+ [AI Instruction]
152
+ ``InboxProcessor`` calls this. An unknown name raises ``KeyError``
153
+ and does not return a message.
154
+ """
155
+
156
+ entry = self._entries.get(message_name_)
157
+ if entry is None:
158
+ raise KeyError(f"Unknown message name {message_name_}")
159
+ message_type, serializer = entry
160
+ codec = serializer or _JsonMessageSerializer()
161
+ return codec.deserialize(payload, message_type)
162
+
163
+ @_bind_self_or_singleton
164
+ def payload_for(self, message: InboxEntry) -> typing.Tuple[str, bytes]:
165
+ """
166
+ Return ``(message_name, payload)`` for a registered message.
167
+
168
+ Raises ``TypeError`` when the name is missing or the instance type
169
+ does not match the registration.
170
+ """
171
+
172
+ if not isinstance(message, (IRequest, IEvent)):
173
+ raise TypeError(f"Unsupported inbox message type {type(message)}")
174
+ name = message_name(message)
175
+ registered = self.get(name)
176
+ if registered is None:
177
+ raise TypeError(f"Unknown message name for {name}")
178
+ if not _type_matches(type(message), registered):
179
+ raise TypeError(
180
+ f"Message type {type(message)} does not match registered type {registered}",
181
+ )
182
+ codec = self.get_serializer(name) or _JsonMessageSerializer()
183
+ return name, codec.serialize(message)
184
+
185
+
186
+ def resolve_message_map(
187
+ message_map: "InboxMessageMap | None",
188
+ ) -> InboxMessageMap:
189
+ """Use the given instance, or the process singleton when ``message_map`` is omitted."""
190
+
191
+ if message_map is None or message_map is InboxMessageMap:
192
+ return InboxMessageMap._singleton()
193
+ return message_map
194
+
195
+
196
+ __all__ = (
197
+ "InboxMessageMap",
198
+ "MessageCodec",
199
+ "message_name",
200
+ "resolve_message_map",
201
+ )
@@ -0,0 +1,110 @@
1
+ """In-memory inbox repository for unit tests."""
2
+
3
+ import contextlib
4
+ import dataclasses
5
+ import datetime
6
+ import typing
7
+
8
+ from cqrs.inbox.map import InboxMessageMap, resolve_message_map
9
+ from cqrs.inbox.repository import (
10
+ MAX_FLUSH_COUNTER_VALUE,
11
+ InboxEntry,
12
+ InboxMessage,
13
+ InboxRepository,
14
+ InboxStatus,
15
+ )
16
+
17
+
18
+ class MemoryInboxRepository(InboxRepository):
19
+ """
20
+ Process-local inbox.
21
+
22
+ ``add`` is visible to ``get_many`` on this instance before ``commit``,
23
+ matching a session that has flushed and not committed. ``rollback``
24
+ restores the last ``commit``. ``nested`` snapshots the rows and restores
25
+ them when the block raises.
26
+ """
27
+
28
+ def __init__(self, message_map: InboxMessageMap | None = None) -> None:
29
+ self._message_map = resolve_message_map(message_map)
30
+ self._rows: typing.List[InboxMessage] = []
31
+ self._checkpoint: typing.List[InboxMessage] = []
32
+ self._next_id = 1
33
+
34
+ async def add(
35
+ self,
36
+ messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
37
+ ) -> typing.List[bool]:
38
+ if not messages:
39
+ return []
40
+ stored = {(row.message_id, row.message_name) for row in self._rows}
41
+ results: typing.List[bool] = []
42
+ for message_id, message in messages:
43
+ name, payload = self._message_map.payload_for(message)
44
+ key = (message_id, name)
45
+ if key in stored:
46
+ results.append(False)
47
+ continue
48
+ self._rows.append(
49
+ InboxMessage(
50
+ id=self._next_id,
51
+ message_id=message_id,
52
+ message_name=name,
53
+ status=InboxStatus.NEW,
54
+ flush_counter=0,
55
+ created_at=datetime.datetime.now(),
56
+ payload=payload,
57
+ ),
58
+ )
59
+ self._next_id += 1
60
+ stored.add(key)
61
+ results.append(True)
62
+ return results
63
+
64
+ async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
65
+ selectable = [
66
+ row
67
+ for row in self._rows
68
+ if row.status in (InboxStatus.NEW, InboxStatus.NOT_PROCESSED)
69
+ and row.flush_counter < MAX_FLUSH_COUNTER_VALUE
70
+ ]
71
+ selectable.sort(key=lambda row: row.id)
72
+ return selectable[:batch_size]
73
+
74
+ async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
75
+ for index, row in enumerate(self._rows):
76
+ if row.id != row_id:
77
+ continue
78
+ flush_counter = row.flush_counter
79
+ if new_status is InboxStatus.NOT_PROCESSED:
80
+ flush_counter += 1
81
+ self._rows[index] = dataclasses.replace(
82
+ row,
83
+ status=new_status,
84
+ flush_counter=flush_counter,
85
+ )
86
+ return
87
+
88
+ def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
89
+ return self._nested()
90
+
91
+ @contextlib.asynccontextmanager
92
+ async def _nested(self) -> typing.AsyncIterator[None]:
93
+ snapshot = [dataclasses.replace(row) for row in self._rows]
94
+ next_id = self._next_id
95
+ try:
96
+ yield
97
+ except BaseException:
98
+ self._rows = snapshot
99
+ self._next_id = next_id
100
+ raise
101
+
102
+ async def commit(self) -> None:
103
+ self._checkpoint = [dataclasses.replace(row) for row in self._rows]
104
+
105
+ async def rollback(self) -> None:
106
+ self._rows = [dataclasses.replace(row) for row in self._checkpoint]
107
+ self._next_id = max((row.id for row in self._rows), default=0) + 1
108
+
109
+
110
+ __all__ = ("MemoryInboxRepository",)
@@ -0,0 +1,116 @@
1
+ """
2
+ Read inbox rows and hand them to the mediator that is already registered.
3
+
4
+ [AI Instruction]
5
+ Run ``InboxProcessor`` in a worker process. It does not replace
6
+ ``repository.add`` in a route or a subscriber. ``process`` does not commit.
7
+ The worker commits the business rows and the status together.
8
+ """
9
+
10
+ import functools
11
+ import logging
12
+ import typing
13
+
14
+ from cqrs.inbox.map import InboxMessageMap
15
+ from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
16
+ from cqrs.models.event import IEvent
17
+ from cqrs.models.request import IRequest
18
+
19
+ logger = logging.getLogger("cqrs")
20
+
21
+
22
+ class InboxProcessor:
23
+ """
24
+ Deserialize one inbox row and send it through an existing mediator.
25
+
26
+ A ``Request`` goes to ``RequestMediator.send``. An event goes to
27
+ ``EventMediator.send``. Each row has its own savepoint: success stores
28
+ ``processed`` inside that savepoint, and a failure stores ``not_processed``
29
+ on the outer transaction after the savepoint rolls back.
30
+
31
+ [AI Instruction]
32
+ Construct it with the repository for this batch's session, the same
33
+ ``InboxMessageMap`` that repository uses, and the mediators bootstrap
34
+ already built. The caller owns the session and the commit. Do not add a
35
+ handler whose only job is to write the inbox row. The handler that
36
+ ``send`` runs must not call ``session.commit``.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ repository: InboxRepository,
42
+ message_map: InboxMessageMap,
43
+ request_mediator: typing.Any | None = None,
44
+ event_mediator: typing.Any | None = None,
45
+ ) -> None:
46
+ self.repository = repository
47
+ self._map = message_map
48
+ self._request_mediator = request_mediator
49
+ self._event_mediator = event_mediator
50
+
51
+ async def batch_generator(
52
+ self,
53
+ batch_size: int = 100,
54
+ ) -> typing.AsyncIterator[typing.List[InboxMessage]]:
55
+ """
56
+ Yield batches until the caller stops the loop.
57
+
58
+ [AI Instruction]
59
+ Poll forever. Each yield is one ``get_many`` result from the
60
+ repository passed to ``__init__``. Process every row, then let the
61
+ caller commit. This generator does not commit and does not end on
62
+ an empty batch.
63
+ """
64
+
65
+ while True:
66
+ yield await self.repository.get_many(batch_size=batch_size)
67
+
68
+ async def process(self, row: InboxMessage) -> None:
69
+ """
70
+ Run one row.
71
+
72
+ [AI Instruction]
73
+ Call this for each row from ``get_many``. The repository is the one
74
+ passed to ``__init__``. A handler exception rolls back that row's
75
+ business writes and leaves neighbors in the batch untouched.
76
+ ``not_processed`` increments ``flush_counter``. The row stays
77
+ selectable until the counter reaches five.
78
+ """
79
+
80
+ try:
81
+ message = self._map.deserialize(row.message_name, row.payload)
82
+ async with self.repository.nested():
83
+ await self._dispatch(message)
84
+ await self.repository.update_status(row.id, InboxStatus.PROCESSED)
85
+ except Exception:
86
+ logger.exception(
87
+ "inbox row %s (%s) failed",
88
+ row.id,
89
+ row.message_name,
90
+ )
91
+ await self.repository.update_status(row.id, InboxStatus.NOT_PROCESSED)
92
+
93
+ @typing.overload
94
+ async def _dispatch(self, message: IRequest) -> None: ...
95
+
96
+ @typing.overload
97
+ async def _dispatch(self, message: IEvent) -> None: ...
98
+
99
+ @functools.singledispatchmethod
100
+ async def _dispatch(self, message: typing.Any) -> None:
101
+ raise TypeError(f"Unsupported inbox message type {type(message)}")
102
+
103
+ @_dispatch.register(IRequest) # pyright: ignore[reportFunctionMemberAccess]
104
+ async def _dispatch_request(self, message: IRequest) -> None:
105
+ if self._request_mediator is None:
106
+ raise RuntimeError("RequestMediator is not configured")
107
+ await self._request_mediator.send(message)
108
+
109
+ @_dispatch.register(IEvent) # pyright: ignore[reportFunctionMemberAccess]
110
+ async def _dispatch_event(self, message: IEvent) -> None:
111
+ if self._event_mediator is None:
112
+ raise RuntimeError("EventMediator is not configured")
113
+ await self._event_mediator.send(message)
114
+
115
+
116
+ __all__ = ("InboxProcessor",)