python-cqrs 5.3.0__py3-none-any.whl → 5.4.0__py3-none-any.whl

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.
cqrs/__init__.py CHANGED
@@ -16,6 +16,11 @@ Startup is not on this package. Import the submodule:
16
16
  - ``cqrs.bootstrap.events.bootstrap`` builds ``EventMediator``.
17
17
  - ``cqrs.bootstrap.saga.bootstrap`` builds ``SagaMediator``.
18
18
 
19
+ Tests import ``cqrs.testing``. ``from cqrs.testing import test_harness`` runs the
20
+ mediators above and records what they did. ``test_harness`` is not in
21
+ ``__all__``: tests are not part of the runtime surface, the same way
22
+ ``cqrs.bootstrap`` is not.
23
+
19
24
  Requests and handlers on ``cqrs``: ``Request``, ``DCRequest``, ``Response``,
20
25
  ``RequestHandler``, ``StreamingRequestHandler``, ``RequestMediator``,
21
26
  ``StreamingRequestMediator``, ``RequestMap``, ``RequestHandlerFallback``.
@@ -25,6 +25,21 @@ logger = logging.getLogger("cqrs")
25
25
  _H: typing.TypeAlias = event_handler.EventHandler
26
26
 
27
27
 
28
+ class EventEmitterListener(typing.Protocol):
29
+ """Optional observer for domain-handler failures and events with no handlers."""
30
+
31
+ def on_domain_handler_error(
32
+ self,
33
+ event: IDomainEvent,
34
+ handler: type,
35
+ error: BaseException,
36
+ ) -> None:
37
+ """Called when a domain handler error is logged and swallowed."""
38
+
39
+ def on_unhandled(self, event: IDomainEvent) -> None:
40
+ """Called when a domain event has no registered handlers."""
41
+
42
+
28
43
  class EventEmitter:
29
44
  """
30
45
  Sends events to registered handlers or to a message broker.
@@ -50,6 +65,7 @@ class EventEmitter:
50
65
  scope_strategy: ScopeStrategy = ScopeStrategy.NONE,
51
66
  *,
52
67
  serializer: EventSerializer | None = None,
68
+ listener: EventEmitterListener | None = None,
53
69
  ) -> None:
54
70
  """
55
71
  Initialize the event emitter.
@@ -61,6 +77,7 @@ class EventEmitter:
61
77
  when emitting :class:`~cqrs.events.event.INotificationEvent`.
62
78
  scope_strategy: DI scope boundary; HANDLER opens a scope per handler.
63
79
  serializer: Codec used to fill ``Message.payload_bytes``. Defaults to JSON.
80
+ listener: Optional observer. ``None`` leaves ``emit`` behavior unchanged.
64
81
 
65
82
  Example::
66
83
 
@@ -78,6 +95,7 @@ class EventEmitter:
78
95
  self._message_broker = message_broker
79
96
  self._scope_strategy = scope_strategy
80
97
  self._serializer = serializer or JsonEventSerializer()
98
+ self._listener = listener
81
99
 
82
100
  @functools.singledispatchmethod
83
101
  async def emit(self, event: IEvent) -> typing.Sequence[IEvent]:
@@ -222,6 +240,8 @@ class EventEmitter:
222
240
  "Handlers for domain event %s not found",
223
241
  type(event).__name__,
224
242
  )
243
+ if self._listener is not None:
244
+ self._listener.on_unhandled(event)
225
245
  return ()
226
246
 
227
247
  results = await self._run_handlers(event, handlers_types)
@@ -241,6 +261,11 @@ class EventEmitter:
241
261
  res,
242
262
  exc_info=res,
243
263
  )
264
+ if self._listener is not None:
265
+ handler_type = (
266
+ handler_item.primary if isinstance(handler_item, EventHandlerFallback) else handler_item
267
+ )
268
+ self._listener.on_domain_handler_error(event, handler_type, res)
244
269
  elif isinstance(res, BaseException):
245
270
  raise res
246
271
  else:
cqrs/outbox/memory.py ADDED
@@ -0,0 +1,96 @@
1
+ """In-memory outbox repository.
2
+
3
+ [AI Instruction]
4
+ Import ``MemoryOutboxRepository`` from this module. It is not an attribute of
5
+ ``cqrs``. Each instance has its own id counter. ``PRODUCED`` updates the row
6
+ status and keeps the row. ``cqrs.outbox.mock.MockOutboxedEventRepository`` is
7
+ a different class and is unchanged.
8
+ """
9
+
10
+ import dataclasses
11
+ import typing
12
+
13
+ from cqrs.mapping import outbox as map
14
+ from cqrs.models.event import INotificationEvent
15
+ from cqrs.outbox import repository
16
+ from cqrs.serializers.json import JsonEventSerializer
17
+ from cqrs.serializers.protocol import EventCodec
18
+
19
+
20
+ class MemoryOutboxRepository(repository.OutboxedEventRepository):
21
+ """
22
+ Process-local outbox.
23
+
24
+ The id counter and the rows belong to the instance. A row marked
25
+ ``PRODUCED`` stays in the repository with that status. ``get_many``
26
+ returns ``NEW`` and ``NOT_PRODUCED`` rows only. ``rollback`` drops rows
27
+ added since the last ``commit``.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ serializer: EventCodec | None = None,
34
+ event_map: map.OutboxedEventMap | None = None,
35
+ ) -> None:
36
+ self._counter = 0
37
+ self._rows: dict[int, repository.OutboxedEvent] = {}
38
+ self._staged: list[int] = []
39
+ self._serializer = serializer or JsonEventSerializer()
40
+ self._event_map = event_map
41
+
42
+ def _resolve_codec(self, event_name: str) -> EventCodec:
43
+ if self._event_map is not None:
44
+ registered = self._event_map.get_serializer(event_name)
45
+ if registered is not None:
46
+ return registered
47
+ return self._serializer
48
+
49
+ def add(self, event: INotificationEvent) -> None:
50
+ self._counter += 1
51
+ row_id = self._counter
52
+ codec = self._resolve_codec(event.event_name)
53
+ self._rows[row_id] = repository.OutboxedEvent(
54
+ id=row_id,
55
+ event=event,
56
+ topic=event.topic,
57
+ status=repository.EventStatus.NEW,
58
+ payload_bytes=codec.serialize(event),
59
+ content_type=codec.content_type_for(event),
60
+ )
61
+ self._staged.append(row_id)
62
+
63
+ async def get_many(
64
+ self,
65
+ batch_size: int = 100,
66
+ topic: typing.Text | None = None,
67
+ ) -> typing.List[repository.OutboxedEvent]:
68
+ selectable = [
69
+ row
70
+ for row in self._rows.values()
71
+ if row.status in (repository.EventStatus.NEW, repository.EventStatus.NOT_PRODUCED)
72
+ and (topic is None or row.topic == topic)
73
+ ]
74
+ selectable.sort(key=lambda row: row.id)
75
+ return selectable[:batch_size]
76
+
77
+ async def update_status(
78
+ self,
79
+ outboxed_event_id: int,
80
+ new_status: repository.EventStatus,
81
+ ) -> None:
82
+ current = self._rows.get(outboxed_event_id)
83
+ if current is None:
84
+ return
85
+ self._rows[outboxed_event_id] = dataclasses.replace(current, status=new_status)
86
+
87
+ async def commit(self) -> None:
88
+ self._staged.clear()
89
+
90
+ async def rollback(self) -> None:
91
+ for row_id in self._staged:
92
+ self._rows.pop(row_id, None)
93
+ self._staged.clear()
94
+
95
+
96
+ __all__ = ("MemoryOutboxRepository",)
cqrs/outbox/repository.py CHANGED
@@ -110,6 +110,11 @@ class OutboxedEventRepository(abc.ABC):
110
110
  """
111
111
  Get many events from the repository.
112
112
 
113
+ SQLAlchemy and SQLModel take rows with ``FOR UPDATE SKIP LOCKED`` on
114
+ PostgreSQL, MySQL 8.0 or later, and MariaDB 10.6 or later, until the
115
+ caller commits or rolls back. Older MySQL and MariaDB, SQLite, and
116
+ other dialects do not.
117
+
113
118
  [AI Instruction]
114
119
  Read a batch of unpublished outbox rows. ``EventProducer`` calls this.
115
120
  A command handler does not.
cqrs/outbox/sqlalchemy.py CHANGED
@@ -47,6 +47,23 @@ DEFAULT_OUTBOX_TABLE_NAME = "outbox"
47
47
 
48
48
  MAX_FLUSH_COUNTER_VALUE = 5
49
49
 
50
+ # SKIP LOCKED arrived in MySQL 8.0 and MariaDB 10.6. PostgreSQL has it
51
+ # without a version gate. Older servers and every other dialect stay unlocked.
52
+ _MYSQL_SKIP_LOCKED_SINCE = (8, 0)
53
+ _MARIADB_SKIP_LOCKED_SINCE = (10, 6)
54
+
55
+
56
+ def _dialect_is_mariadb(dialect: typing.Any) -> bool:
57
+ """MariaDB via ``mariadb://`` or the MySQL dialect's ``is_mariadb`` flag.
58
+
59
+ A MariaDB reached through the MySQL dialect keeps ``dialect.name == "mysql"``
60
+ and a version such as ``(10, 5)``, which would pass a MySQL 8.0 check.
61
+ """
62
+
63
+ if getattr(dialect, "name", None) == "mariadb":
64
+ return True
65
+ return bool(getattr(dialect, "is_mariadb", False))
66
+
50
67
 
51
68
  # Deprecated name of cqrs.sqlalchemy_types.UUIDBinary — kept importable for
52
69
  # backward compatibility (e.g. already generated Alembic migrations).
@@ -125,8 +142,10 @@ class OutboxModel(Base):
125
142
  cls,
126
143
  size: int,
127
144
  topic: typing.Text | None = None,
145
+ *,
146
+ lock: bool = False,
128
147
  ) -> sqlalchemy.Select:
129
- return (
148
+ query = (
130
149
  sqlalchemy.select(cls)
131
150
  .select_from(cls)
132
151
  .where(
@@ -145,6 +164,9 @@ class OutboxModel(Base):
145
164
  .order_by(cls.id.asc())
146
165
  .limit(size)
147
166
  )
167
+ if lock:
168
+ query = query.with_for_update(skip_locked=True)
169
+ return query
148
170
 
149
171
  @classmethod
150
172
  def update_status_query(
@@ -277,13 +299,44 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
277
299
  content_type=codec.content_type_for(event),
278
300
  )
279
301
 
302
+ async def _skip_locked(self) -> bool:
303
+ dialect = self.session.get_bind().dialect
304
+ name = dialect.name
305
+ if name == "postgresql":
306
+ return True
307
+ if name != "mysql" and name != "mariadb":
308
+ return False
309
+
310
+ # The dialect leaves server_version_info unset until the first
311
+ # connection runs initialize(). Reading it earlier looks like an old
312
+ # server and drops SKIP LOCKED on MySQL 8 and MariaDB 10.6.
313
+ version = getattr(dialect, "server_version_info", None)
314
+ if version is None:
315
+ await self.session.connection()
316
+ version = getattr(dialect, "server_version_info", None)
317
+ if version is None:
318
+ return False
319
+
320
+ minimum = _MARIADB_SKIP_LOCKED_SINCE if _dialect_is_mariadb(dialect) else _MYSQL_SKIP_LOCKED_SINCE
321
+ return tuple(version) >= minimum
322
+
280
323
  async def get_many(
281
324
  self,
282
325
  batch_size: int = 100,
283
326
  topic: typing.Text | None = None,
284
327
  ) -> typing.List[repository.OutboxedEvent]:
285
328
  events: typing.Sequence[typing.Any] = (
286
- (await self.session.execute(self._model.get_batch_query(batch_size, topic))).scalars().all()
329
+ (
330
+ await self.session.execute(
331
+ self._model.get_batch_query(
332
+ batch_size,
333
+ topic,
334
+ lock=await self._skip_locked(),
335
+ ),
336
+ )
337
+ )
338
+ .scalars()
339
+ .all()
287
340
  )
288
341
 
289
342
  result = []
cqrs/outbox/sqlmodel.py CHANGED
@@ -140,9 +140,11 @@ class OutboxModel(Base, table=True):
140
140
  cls,
141
141
  size: int,
142
142
  topic: typing.Text | None = None,
143
+ *,
144
+ lock: bool = False,
143
145
  ) -> sqlalchemy.Select:
144
146
  query = SqlAlchemyOutboxModel.get_batch_query
145
- return query.__func__(cls, size, topic) # type: ignore[attr-defined]
147
+ return query.__func__(cls, size, topic, lock=lock) # type: ignore[attr-defined]
146
148
 
147
149
  @classmethod
148
150
  def update_status_query(
@@ -0,0 +1,23 @@
1
+ """Test harness for python-cqrs.
2
+
3
+ [AI Instruction]
4
+ Import ``test_harness`` from this package::
5
+
6
+ from cqrs.testing import test_harness
7
+
8
+ ``cqrs.test_harness`` does not exist, and the name is not in ``cqrs.__all__``.
9
+ The harness runs the real mediators and records invocations, handled domain
10
+ events, broker messages, outbox rows, requests, stream yields, middleware,
11
+ handler errors, unhandled events, sagas, and inbox rows.
12
+ """
13
+
14
+ from cqrs.testing.broker import CapturingMessageBroker
15
+ from cqrs.testing.container import TestContainer
16
+ from cqrs.testing.harness import TestHarness, test_harness
17
+
18
+ __all__ = (
19
+ "CapturingMessageBroker",
20
+ "TestContainer",
21
+ "TestHarness",
22
+ "test_harness",
23
+ )
cqrs/testing/broker.py ADDED
@@ -0,0 +1,44 @@
1
+ """In-memory broker that records messages sent by the mediators.
2
+
3
+ [AI Instruction]
4
+ Import ``CapturingMessageBroker`` from ``cqrs.testing``. Pass it to
5
+ ``bootstrap`` as ``message_broker``, or let ``test_harness`` create one.
6
+ ``fail_next`` raises on the following ``send_message`` and does not record
7
+ that message.
8
+ """
9
+
10
+ from cqrs.message_brokers.protocol import Message, MessageBroker
11
+
12
+
13
+ class CapturingMessageBroker(MessageBroker):
14
+ """
15
+ Drop-in broker that keeps every delivered message.
16
+
17
+ ``fail_next`` schedules an exception for the next ``send_message``.
18
+ The failed send is not appended. An optional ``inner`` broker is called
19
+ after the message is accepted, so a real adapter can sit behind the capture.
20
+ """
21
+
22
+ def __init__(self, inner: MessageBroker | None = None) -> None:
23
+ self._inner = inner
24
+ self._messages: list[Message] = []
25
+ self._failures: list[BaseException] = []
26
+
27
+ @property
28
+ def messages(self) -> list[Message]:
29
+ return self._messages
30
+
31
+ def fail_next(self, error: BaseException) -> None:
32
+ """Raise ``error`` from the next ``send_message``."""
33
+
34
+ self._failures.append(error)
35
+
36
+ async def send_message(self, message: Message) -> None:
37
+ if self._failures:
38
+ raise self._failures.pop(0)
39
+ if self._inner is not None:
40
+ await self._inner.send_message(message)
41
+ self._messages.append(message)
42
+
43
+
44
+ __all__ = ("CapturingMessageBroker",)
@@ -0,0 +1,200 @@
1
+ """Container for harness tests and the resolve wrapper that records handler entry.
2
+
3
+ [AI Instruction]
4
+ ``TestContainer.register`` stores one instance. ``resolve`` returns it, or
5
+ constructs the type with no arguments. ``ScopeStrategy.SEND`` needs a
6
+ ``di.Container``, not ``TestContainer``. The resolve wrapper is what
7
+ ``test_harness`` installs on the mediators' containers.
8
+ """
9
+
10
+ import inspect
11
+ import typing
12
+ from contextvars import ContextVar
13
+
14
+ from cqrs.testing.recording import InvocationJournal
15
+
16
+ _RECORDING = "_cqrs_testing_recording"
17
+ _ACTIVE_JOURNAL: ContextVar[InvocationJournal | None] = ContextVar(
18
+ "cqrs_testing_active_invocation_journal",
19
+ default=None,
20
+ )
21
+ _T = typing.TypeVar("_T")
22
+
23
+
24
+ class TestContainer:
25
+ """
26
+ Small container for harness tests.
27
+
28
+ ``register(type, instance)`` pins that instance. ``resolve`` returns the
29
+ registration or calls ``type_()``.
30
+ """
31
+
32
+ __test__ = False
33
+
34
+ def __init__(self) -> None:
35
+ self._registry: dict[type, typing.Any] = {}
36
+
37
+ def register(self, type_: type, instance: typing.Any) -> None:
38
+ self._registry[type_] = instance
39
+
40
+ @property
41
+ def external_container(self) -> "TestContainer":
42
+ return self
43
+
44
+ def attach_external_container(self, container: typing.Any) -> None:
45
+ return None
46
+
47
+ async def resolve(self, type_: typing.Type[_T]) -> _T:
48
+ if type_ in self._registry:
49
+ return typing.cast(_T, self._registry[type_])
50
+ return type_()
51
+
52
+
53
+ def _first_argument(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> typing.Any:
54
+ if args:
55
+ return args[0]
56
+ for name in ("request", "event", "context"):
57
+ if name in kwargs:
58
+ return kwargs[name]
59
+ if kwargs:
60
+ return next(iter(kwargs.values()))
61
+ return None
62
+
63
+
64
+ def _record_entry(
65
+ handler_type: type,
66
+ method_name: str,
67
+ args: tuple[typing.Any, ...],
68
+ kwargs: dict[str, typing.Any],
69
+ instance: typing.Any,
70
+ ) -> None:
71
+ journal = _ACTIVE_JOURNAL.get()
72
+ if journal is None:
73
+ return
74
+ journal.record(
75
+ handler_type,
76
+ method_name,
77
+ _first_argument(args, kwargs),
78
+ instance,
79
+ )
80
+
81
+
82
+ def _wrap_coroutine(
83
+ original: typing.Callable[..., typing.Awaitable[typing.Any]],
84
+ handler_type: type,
85
+ method_name: str,
86
+ instance: typing.Any,
87
+ ) -> typing.Callable[..., typing.Awaitable[typing.Any]]:
88
+ async def wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
89
+ _record_entry(handler_type, method_name, args, kwargs, instance)
90
+ return await original(*args, **kwargs)
91
+
92
+ setattr(wrapped, _RECORDING, True)
93
+ return wrapped
94
+
95
+
96
+ def _wrap_async_gen(
97
+ original: typing.Callable[..., typing.AsyncIterator[typing.Any]],
98
+ handler_type: type,
99
+ method_name: str,
100
+ instance: typing.Any,
101
+ ) -> typing.Callable[..., typing.AsyncIterator[typing.Any]]:
102
+ async def wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.AsyncIterator[typing.Any]:
103
+ _record_entry(handler_type, method_name, args, kwargs, instance)
104
+ async for item in original(*args, **kwargs):
105
+ yield item
106
+
107
+ setattr(wrapped, _RECORDING, True)
108
+ return wrapped
109
+
110
+
111
+ def _restore_method(
112
+ instance: typing.Any,
113
+ method_name: str,
114
+ previous: typing.Any,
115
+ had_attr: bool,
116
+ ) -> None:
117
+ if had_attr:
118
+ setattr(instance, method_name, previous)
119
+ return
120
+ try:
121
+ delattr(instance, method_name)
122
+ except AttributeError:
123
+ return
124
+
125
+
126
+ def instrument_instance(
127
+ instance: typing.Any,
128
+ handler_type: type,
129
+ undo: list[typing.Callable[[], None]],
130
+ ) -> None:
131
+ """Record entry into ``handle``, ``act``, and ``compensate`` on ``instance``."""
132
+
133
+ for method_name in ("handle", "act", "compensate"):
134
+ current = getattr(instance, method_name, None)
135
+ if current is None or not callable(current):
136
+ continue
137
+ if getattr(current, _RECORDING, False):
138
+ continue
139
+ if inspect.isasyncgenfunction(current):
140
+ replacement = _wrap_async_gen(current, handler_type, method_name, instance)
141
+ elif inspect.iscoroutinefunction(current):
142
+ replacement = _wrap_coroutine(current, handler_type, method_name, instance)
143
+ else:
144
+ continue
145
+ instance_dict = getattr(instance, "__dict__", None)
146
+ had_attr = isinstance(instance_dict, dict) and method_name in instance_dict
147
+ setattr(instance, method_name, replacement)
148
+ undo.append(
149
+ lambda inst=instance, name=method_name, previous=current, owned=had_attr: _restore_method(
150
+ inst,
151
+ name,
152
+ previous,
153
+ owned,
154
+ ),
155
+ )
156
+
157
+
158
+ def wrap_resolve(container: typing.Any, journal: InvocationJournal) -> typing.Callable[[], None]:
159
+ """
160
+ Wrap ``container.resolve`` so each handler records entry.
161
+
162
+ Recording follows the active harness journal. ``restore`` drops instance
163
+ patches this wrap installed and puts the previous ``resolve`` back.
164
+ A nested wrap on an already wrapped container only switches that journal.
165
+ """
166
+
167
+ token = _ACTIVE_JOURNAL.set(journal)
168
+ original = container.resolve
169
+ if getattr(original, _RECORDING, False):
170
+
171
+ def restore_journal() -> None:
172
+ _ACTIVE_JOURNAL.reset(token)
173
+
174
+ return restore_journal
175
+
176
+ undo: list[typing.Callable[[], None]] = []
177
+
178
+ async def resolve(type_: typing.Type[_T]) -> _T:
179
+ instance = await original(type_)
180
+ instrument_instance(instance, type_, undo)
181
+ return instance
182
+
183
+ setattr(resolve, _RECORDING, True)
184
+ container.resolve = resolve
185
+
186
+ def restore() -> None:
187
+ for undo_patch in reversed(undo):
188
+ undo_patch()
189
+ undo.clear()
190
+ container.resolve = original
191
+ _ACTIVE_JOURNAL.reset(token)
192
+
193
+ return restore
194
+
195
+
196
+ __all__ = (
197
+ "TestContainer",
198
+ "instrument_instance",
199
+ "wrap_resolve",
200
+ )