python-cqrs 5.3.1__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",)
@@ -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
+ )