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.
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/PKG-INFO +2 -1
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/pyproject.toml +2 -1
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/__init__.py +33 -0
- python_cqrs-5.3.0/src/cqrs/inbox/__init__.py +21 -0
- python_cqrs-5.3.0/src/cqrs/inbox/map.py +201 -0
- python_cqrs-5.3.0/src/cqrs/inbox/memory.py +110 -0
- python_cqrs-5.3.0/src/cqrs/inbox/processor.py +116 -0
- python_cqrs-5.3.0/src/cqrs/inbox/repository.py +149 -0
- python_cqrs-5.3.0/src/cqrs/inbox/sqlalchemy.py +322 -0
- python_cqrs-5.3.0/src/cqrs/inbox/sqlmodel.py +164 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/PKG-INFO +2 -1
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/SOURCES.txt +7 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/LICENSE +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/README.md +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/setup.cfg +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/_dataclass_utils.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/events.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/requests.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/bootstrap/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/circuit_breaker.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/di.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/dishka.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/container/scope.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/exceptions.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/json.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/deserializers/protobuf.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/event.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/exceptions.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/models.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/request.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/dispatcher/streaming.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/event_emitter.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/event_processor.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/events/fallback.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/generic_utils.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/cor.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/event.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/request.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/handlers/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/events.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/outbox.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mapping/requests.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/_common.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/event.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/request.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mediators/streaming.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/cor.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/mermaid/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/producer.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/base.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/middlewares/logging.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/event.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/pydantic.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/request.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/models/response.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/mock.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/repository.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/sqlalchemy.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/outbox/sqlmodel.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/py.typed +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/requests/fallback.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/circuit_breaker.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/compensation.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/execution.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/fallback.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/models.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/recovery.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/saga.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/enums.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/memory.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/models.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/sqlalchemy.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/storage/sqlmodel.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/saga/validation.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/json.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/protobuf.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/serializers/protocol.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/sqlalchemy_types.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/sqlmodel_base.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/cqrs/types.py +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-5.2.0 → python_cqrs-5.3.0}/src/python_cqrs.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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",)
|