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 +5 -0
- cqrs/events/event_emitter.py +25 -0
- cqrs/outbox/memory.py +96 -0
- cqrs/outbox/repository.py +5 -0
- cqrs/outbox/sqlalchemy.py +55 -2
- cqrs/outbox/sqlmodel.py +3 -1
- cqrs/testing/__init__.py +23 -0
- cqrs/testing/broker.py +44 -0
- cqrs/testing/container.py +200 -0
- cqrs/testing/harness.py +560 -0
- cqrs/testing/recording.py +875 -0
- python_cqrs-5.4.0.dist-info/METADATA +399 -0
- {python_cqrs-5.3.0.dist-info → python_cqrs-5.4.0.dist-info}/RECORD +16 -10
- python_cqrs-5.3.0.dist-info/METADATA +0 -1314
- {python_cqrs-5.3.0.dist-info → python_cqrs-5.4.0.dist-info}/WHEEL +0 -0
- {python_cqrs-5.3.0.dist-info → python_cqrs-5.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_cqrs-5.3.0.dist-info → python_cqrs-5.4.0.dist-info}/top_level.txt +0 -0
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``.
|
cqrs/events/event_emitter.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
(
|
|
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(
|
cqrs/testing/__init__.py
ADDED
|
@@ -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
|
+
)
|