eventsourcing 9.5.0b3__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.
- eventsourcing/__init__.py +0 -0
- eventsourcing/application.py +998 -0
- eventsourcing/cipher.py +107 -0
- eventsourcing/compressor.py +15 -0
- eventsourcing/cryptography.py +91 -0
- eventsourcing/dcb/__init__.py +0 -0
- eventsourcing/dcb/api.py +144 -0
- eventsourcing/dcb/application.py +159 -0
- eventsourcing/dcb/domain.py +369 -0
- eventsourcing/dcb/msgpack.py +38 -0
- eventsourcing/dcb/persistence.py +193 -0
- eventsourcing/dcb/popo.py +178 -0
- eventsourcing/dcb/postgres_tt.py +704 -0
- eventsourcing/dcb/tests.py +608 -0
- eventsourcing/dispatch.py +80 -0
- eventsourcing/domain.py +1964 -0
- eventsourcing/interface.py +164 -0
- eventsourcing/persistence.py +1429 -0
- eventsourcing/popo.py +267 -0
- eventsourcing/postgres.py +1441 -0
- eventsourcing/projection.py +502 -0
- eventsourcing/py.typed +0 -0
- eventsourcing/sqlite.py +816 -0
- eventsourcing/system.py +1203 -0
- eventsourcing/tests/__init__.py +3 -0
- eventsourcing/tests/application.py +483 -0
- eventsourcing/tests/domain.py +105 -0
- eventsourcing/tests/persistence.py +1744 -0
- eventsourcing/tests/postgres_utils.py +131 -0
- eventsourcing/utils.py +257 -0
- eventsourcing-9.5.0b3.dist-info/METADATA +253 -0
- eventsourcing-9.5.0b3.dist-info/RECORD +35 -0
- eventsourcing-9.5.0b3.dist-info/WHEEL +4 -0
- eventsourcing-9.5.0b3.dist-info/licenses/AUTHORS +10 -0
- eventsourcing-9.5.0b3.dist-info/licenses/LICENSE +29 -0
eventsourcing/domain.py
ADDED
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
from abc import ABCMeta
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, tzinfo
|
|
13
|
+
from functools import cache
|
|
14
|
+
from types import FunctionType, WrapperDescriptorType
|
|
15
|
+
from typing import (
|
|
16
|
+
TYPE_CHECKING,
|
|
17
|
+
Any,
|
|
18
|
+
ClassVar,
|
|
19
|
+
Generic,
|
|
20
|
+
Protocol,
|
|
21
|
+
TypeVar,
|
|
22
|
+
cast,
|
|
23
|
+
get_args,
|
|
24
|
+
get_origin,
|
|
25
|
+
overload,
|
|
26
|
+
runtime_checkable,
|
|
27
|
+
)
|
|
28
|
+
from uuid import UUID, uuid4
|
|
29
|
+
from warnings import warn
|
|
30
|
+
|
|
31
|
+
from eventsourcing.utils import (
|
|
32
|
+
TopicError,
|
|
33
|
+
get_method_name,
|
|
34
|
+
get_topic,
|
|
35
|
+
register_topic,
|
|
36
|
+
resolve_topic,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Iterable, Sequence
|
|
41
|
+
|
|
42
|
+
from typing_extensions import Self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TZINFO: tzinfo = resolve_topic(os.getenv("TZINFO_TOPIC", "datetime:timezone.utc"))
|
|
46
|
+
"""
|
|
47
|
+
A Python :py:obj:`tzinfo` object that defaults to UTC (:py:obj:`timezone.utc`). Used
|
|
48
|
+
as the timezone argument in :func:`~eventsourcing.domain.datetime_now_with_tzinfo`.
|
|
49
|
+
|
|
50
|
+
Set environment variable ``TZINFO_TOPIC`` to the topic of a different :py:obj:`tzinfo`
|
|
51
|
+
object so that all your domain model event timestamps are located in that timezone
|
|
52
|
+
(not recommended). It is generally recommended to locate all timestamps in the UTC
|
|
53
|
+
domain and convert to local timezones when presenting values in user interfaces.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventsourcingType(type):
|
|
58
|
+
"""Base type for event sourcing domain model types (aggregates and events)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_T = TypeVar("_T")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def patch_dataclasses_process_class() -> None:
|
|
65
|
+
dataclasses_module = importlib.import_module("dataclasses")
|
|
66
|
+
original_process_class_func = dataclasses_module.__dict__["_process_class"]
|
|
67
|
+
|
|
68
|
+
def _patched_dataclasses_process_class(
|
|
69
|
+
cls: type[_T], *args: Any, **kwargs: Any
|
|
70
|
+
) -> type[_T]:
|
|
71
|
+
# Avoid processing aggregate and event dataclasses twice,
|
|
72
|
+
# because doing so screws up non-init and default fields.
|
|
73
|
+
if (
|
|
74
|
+
cls
|
|
75
|
+
and isinstance(cls, EventsourcingType)
|
|
76
|
+
and "__dataclass_fields__" in cls.__dict__
|
|
77
|
+
):
|
|
78
|
+
return cls
|
|
79
|
+
return original_process_class_func(cls, *args, **kwargs)
|
|
80
|
+
|
|
81
|
+
dataclasses_module.__dict__["_process_class"] = _patched_dataclasses_process_class
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
patch_dataclasses_process_class()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
TAggregateID = TypeVar("TAggregateID", bound=UUID | str)
|
|
88
|
+
TAggregateID_co = TypeVar("TAggregateID_co", bound=UUID | str, covariant=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@runtime_checkable
|
|
92
|
+
class DomainEventProtocol(Protocol[TAggregateID_co]):
|
|
93
|
+
"""Protocol for domain event objects.
|
|
94
|
+
|
|
95
|
+
A protocol is defined to allow the event sourcing mechanisms
|
|
96
|
+
to work with different kinds of domain event classes. Whilst
|
|
97
|
+
the library by default uses frozen dataclasses to implement
|
|
98
|
+
its domain event classes, it is also possible to use other
|
|
99
|
+
kinds of domain event classes, such as Pydantic classes.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
103
|
+
pass # pragma: no cover
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def originator_id(self) -> TAggregateID_co:
|
|
107
|
+
"""UUID identifying an aggregate to which the event belongs."""
|
|
108
|
+
raise NotImplementedError # pragma: no cover
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def originator_version(self) -> int:
|
|
112
|
+
"""Integer identifying the version of the aggregate when the event occurred."""
|
|
113
|
+
raise NotImplementedError # pragma: no cover
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
TDomainEvent = TypeVar("TDomainEvent", bound=DomainEventProtocol[Any])
|
|
117
|
+
SDomainEvent = TypeVar("SDomainEvent", bound=DomainEventProtocol[Any])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MutableAggregateProtocol(Protocol[TAggregateID_co]):
|
|
121
|
+
"""Protocol for mutable aggregate objects.
|
|
122
|
+
|
|
123
|
+
A protocol is defined to allow the event sourcing mechanisms
|
|
124
|
+
to work with different kinds of aggregate classes. Whilst
|
|
125
|
+
the library by default recommends using mutable classes to
|
|
126
|
+
implement aggregate classes, it is also possible to implement
|
|
127
|
+
immutable aggregate classes, and this is supported by this library.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def id(self) -> TAggregateID_co:
|
|
132
|
+
"""Mutable aggregates have a read-only ID that is a UUID."""
|
|
133
|
+
raise NotImplementedError # pragma: no cover
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def version(self) -> int:
|
|
137
|
+
"""Mutable aggregates have a read-write version that is an int."""
|
|
138
|
+
raise NotImplementedError # pragma: no cover
|
|
139
|
+
|
|
140
|
+
@version.setter
|
|
141
|
+
def version(self, value: int) -> None:
|
|
142
|
+
"""Mutable aggregates have a read-write version that is an int."""
|
|
143
|
+
raise NotImplementedError # pragma: no cover
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ImmutableAggregateProtocol(Protocol[TAggregateID_co]):
|
|
147
|
+
"""Protocol for immutable aggregate objects.
|
|
148
|
+
|
|
149
|
+
A protocol is defined to allow the event sourcing mechanisms
|
|
150
|
+
to work with different kinds of aggregate classes. Whilst
|
|
151
|
+
the library by default recommends using mutable classes to
|
|
152
|
+
implement aggregate classes, it is also possible to implement
|
|
153
|
+
immutable aggregate classes, and this is supported by this library.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def id(self) -> TAggregateID_co:
|
|
158
|
+
"""Immutable aggregates have a read-only ID that is a UUID."""
|
|
159
|
+
raise NotImplementedError # pragma: no cover
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def version(self) -> int:
|
|
163
|
+
"""Immutable aggregates have a read-only version that is an int."""
|
|
164
|
+
raise NotImplementedError # pragma: no cover
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
MutableOrImmutableAggregate = (
|
|
168
|
+
ImmutableAggregateProtocol[TAggregateID] | MutableAggregateProtocol[TAggregateID]
|
|
169
|
+
)
|
|
170
|
+
"""Type alias defining a union of mutable and immutable aggregate protocols."""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
TMutableOrImmutableAggregate = TypeVar(
|
|
174
|
+
"TMutableOrImmutableAggregate", bound=MutableOrImmutableAggregate[Any]
|
|
175
|
+
)
|
|
176
|
+
"""Type variable bound by the union of mutable and immutable aggregate protocols."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@runtime_checkable
|
|
180
|
+
class CollectEventsProtocol(Protocol):
|
|
181
|
+
"""Protocol for aggregates that support collecting pending events."""
|
|
182
|
+
|
|
183
|
+
def collect_events(self) -> Sequence[DomainEventProtocol[Any]]:
|
|
184
|
+
"""Returns a sequence of events."""
|
|
185
|
+
raise NotImplementedError # pragma: no cover
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@runtime_checkable
|
|
189
|
+
class CanMutateProtocol(
|
|
190
|
+
DomainEventProtocol[Any], Protocol[TMutableOrImmutableAggregate]
|
|
191
|
+
):
|
|
192
|
+
"""Protocol for events that have a mutate method."""
|
|
193
|
+
|
|
194
|
+
def mutate(
|
|
195
|
+
self, aggregate: TMutableOrImmutableAggregate | None
|
|
196
|
+
) -> TMutableOrImmutableAggregate | None:
|
|
197
|
+
"""
|
|
198
|
+
Evolves the state of the given aggregate, either by
|
|
199
|
+
returning the given aggregate instance with modified attributes
|
|
200
|
+
or by constructing and returning a new aggregate instance.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def datetime_now_with_tzinfo() -> datetime:
|
|
205
|
+
"""
|
|
206
|
+
Constructs a timezone-aware :class:`datetime`
|
|
207
|
+
object for the current date and time.
|
|
208
|
+
|
|
209
|
+
Uses :py:obj:`TZINFO` as the timezone.
|
|
210
|
+
"""
|
|
211
|
+
return datetime.now(tz=TZINFO)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_utc_datetime_now() -> datetime:
|
|
215
|
+
"""Deprected in favour of :func:`~eventsourcing.domain.datetime_now_with_tzinfo`."""
|
|
216
|
+
msg = (
|
|
217
|
+
"'create_utc_datetime_now()' is deprecated, "
|
|
218
|
+
"use 'datetime_now_with_tzinfo()' instead"
|
|
219
|
+
)
|
|
220
|
+
warn(msg, DeprecationWarning, stacklevel=2)
|
|
221
|
+
return datetime_now_with_tzinfo()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class CanCreateTimestamp:
|
|
225
|
+
"""Provides a create_timestamp() method to subclasses."""
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def create_timestamp() -> datetime:
|
|
229
|
+
"""Constructs a timezone-aware :class:`datetime` object
|
|
230
|
+
representing when an event occurred.
|
|
231
|
+
"""
|
|
232
|
+
return datetime_now_with_tzinfo()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
TAggregate = TypeVar("TAggregate", bound="BaseAggregate[Any]")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class AbstractDecision:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class HasOriginatorIDVersion(AbstractDecision, Generic[TAggregateID]):
|
|
243
|
+
"""Declares ``originator_id`` and ``originator_version`` attributes."""
|
|
244
|
+
|
|
245
|
+
originator_id: TAggregateID
|
|
246
|
+
"""UUID identifying an aggregate to which the event belongs."""
|
|
247
|
+
originator_version: int
|
|
248
|
+
"""Integer identifying the version of the aggregate when the event occurred."""
|
|
249
|
+
|
|
250
|
+
originator_id_type: ClassVar[type[UUID | str] | None] = None
|
|
251
|
+
|
|
252
|
+
def __init_subclass__(cls) -> None:
|
|
253
|
+
cls.find_originator_id_type(HasOriginatorIDVersion)
|
|
254
|
+
super().__init_subclass__()
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def find_originator_id_type(cls: type, generic_cls: type) -> None:
|
|
258
|
+
"""Store the type argument of TAggregateID on the subclass."""
|
|
259
|
+
if "originator_id_type" not in cls.__dict__:
|
|
260
|
+
for orig_base in cls.__orig_bases__: # type: ignore[attr-defined]
|
|
261
|
+
if "originator_id_type" in orig_base.__dict__:
|
|
262
|
+
cls.originator_id_type = orig_base.__dict__["originator_id_type"] # type: ignore[attr-defined]
|
|
263
|
+
elif get_origin(orig_base) is generic_cls:
|
|
264
|
+
originator_id_type = get_args(orig_base)[0]
|
|
265
|
+
if originator_id_type in (UUID, str):
|
|
266
|
+
cls.originator_id_type = originator_id_type # type: ignore[attr-defined]
|
|
267
|
+
break
|
|
268
|
+
if originator_id_type is Any:
|
|
269
|
+
continue
|
|
270
|
+
if isinstance(originator_id_type, TypeVar):
|
|
271
|
+
continue
|
|
272
|
+
msg = f"Aggregate ID type arg cannot be {originator_id_type}"
|
|
273
|
+
raise TypeError(msg)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class CanMutateAggregate(HasOriginatorIDVersion[TAggregateID], CanCreateTimestamp):
|
|
277
|
+
"""Implements a :py:func:`~eventsourcing.domain.CanMutateAggregate.mutate`
|
|
278
|
+
method that evolves the state of an aggregate.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
# TODO: Move this to a HasTimestamp? Why is it here??
|
|
282
|
+
timestamp: datetime
|
|
283
|
+
"""Timezone-aware :class:`datetime` object representing when an event occurred."""
|
|
284
|
+
|
|
285
|
+
def __init_subclass__(cls) -> None:
|
|
286
|
+
cls.find_originator_id_type(CanMutateAggregate)
|
|
287
|
+
super().__init_subclass__()
|
|
288
|
+
|
|
289
|
+
def mutate(self, aggregate: TAggregate | None) -> TAggregate | None:
|
|
290
|
+
"""Validates and adjusts the attributes of the given ``aggregate``
|
|
291
|
+
argument. The argument is typed as ``Optional``, but the value is
|
|
292
|
+
expected to be not ``None``.
|
|
293
|
+
|
|
294
|
+
Validates the ``aggregate`` argument by checking the event's
|
|
295
|
+
:py:attr:`~eventsourcing.domain.HasOriginatorIDVersion.originator_id` equals the
|
|
296
|
+
``aggregate``'s :py:attr:`~eventsourcing.domain.Aggregate.id`, and the event's
|
|
297
|
+
:py:attr:`~eventsourcing.domain.HasOriginatorIDVersion.originator_version` is
|
|
298
|
+
one greater than the ``aggregate``'s current
|
|
299
|
+
:py:attr:`~eventsourcing.domain.Aggregate.version`.
|
|
300
|
+
If the ``aggregate`` argument is not valid, an exception is raised.
|
|
301
|
+
|
|
302
|
+
If the ``aggregate`` argument is valid, the
|
|
303
|
+
:func:`~eventsourcing.domain.CanMutateAggregate.apply` method is called, and
|
|
304
|
+
then :py:attr:`~eventsourcing.domain.HasOriginatorIDVersion.originator_id` is
|
|
305
|
+
assigned to the aggregate's :py:attr:`~eventsourcing.domain.Aggregate.version`
|
|
306
|
+
attribute, and the ``timestamp`` is assigned to the aggregate's
|
|
307
|
+
:py:attr:`~eventsourcing.domain.Aggregate.modified_on` attribute.
|
|
308
|
+
"""
|
|
309
|
+
assert aggregate is not None
|
|
310
|
+
|
|
311
|
+
# Check this event belongs to this aggregate.
|
|
312
|
+
if self.originator_id != aggregate.id:
|
|
313
|
+
raise OriginatorIDError(self.originator_id, aggregate.id)
|
|
314
|
+
|
|
315
|
+
# Check this event is the next in its sequence.
|
|
316
|
+
next_version = aggregate.version + 1
|
|
317
|
+
if self.originator_version != next_version:
|
|
318
|
+
raise OriginatorVersionError(self.originator_version, next_version)
|
|
319
|
+
|
|
320
|
+
# Call apply() before mutating values, in case exception is raised.
|
|
321
|
+
self.apply(aggregate)
|
|
322
|
+
|
|
323
|
+
# Update the aggregate's 'version' number.
|
|
324
|
+
aggregate.version = self.originator_version
|
|
325
|
+
|
|
326
|
+
# Update the aggregate's 'modified on' time.
|
|
327
|
+
aggregate.modified_on = self.timestamp
|
|
328
|
+
|
|
329
|
+
# Return the mutated aggregate.
|
|
330
|
+
return aggregate
|
|
331
|
+
|
|
332
|
+
def apply(self, aggregate: Any) -> None:
|
|
333
|
+
"""Applies the domain event to its aggregate.
|
|
334
|
+
|
|
335
|
+
This method does nothing but exist to be
|
|
336
|
+
overridden as a convenient way for users
|
|
337
|
+
to define how an event evolves the state
|
|
338
|
+
of an aggregate.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def _as_dict(self) -> dict[str, Any]:
|
|
342
|
+
return self.__dict__
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class CanInitAggregate(CanMutateAggregate[TAggregateID]):
|
|
346
|
+
"""Implements a :func:`~eventsourcing.domain.CanMutateAggregate.mutate`
|
|
347
|
+
method that constructs the initial state of an aggregate.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
originator_topic: str
|
|
351
|
+
"""String describing the path to an aggregate class."""
|
|
352
|
+
|
|
353
|
+
def __init_subclass__(cls) -> None:
|
|
354
|
+
cls.find_originator_id_type(CanInitAggregate)
|
|
355
|
+
super().__init_subclass__()
|
|
356
|
+
|
|
357
|
+
def mutate(self, aggregate: TAggregate | None) -> TAggregate | None:
|
|
358
|
+
"""Constructs an aggregate instance according to the attributes of an event.
|
|
359
|
+
|
|
360
|
+
The ``aggregate`` argument is typed as an optional argument, but the
|
|
361
|
+
value is expected to be ``None``.
|
|
362
|
+
"""
|
|
363
|
+
assert aggregate is None
|
|
364
|
+
|
|
365
|
+
# Resolve originator topic.
|
|
366
|
+
aggregate_class: type[TAggregate] = resolve_topic(self.originator_topic)
|
|
367
|
+
|
|
368
|
+
# Construct an aggregate object (a "shell" of the correct object type).
|
|
369
|
+
agg = aggregate_class.__new__(aggregate_class)
|
|
370
|
+
|
|
371
|
+
# Pick out event attributes for the aggregate base class init method.
|
|
372
|
+
self_dict = self._as_dict()
|
|
373
|
+
base_kwargs = filter_kwargs_for_method_params(
|
|
374
|
+
self_dict, type(agg).__base_init__
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Call the base class init method (so we don't need to always write
|
|
378
|
+
# a call to super().__init__() in every aggregate __init__() method).
|
|
379
|
+
agg.__base_init__(**base_kwargs)
|
|
380
|
+
|
|
381
|
+
# Pick out event attributes for aggregate subclass class init method.
|
|
382
|
+
init_kwargs = filter_kwargs_for_method_params(self_dict, type(agg).__init__)
|
|
383
|
+
|
|
384
|
+
# Provide the aggregate id, if the __init__ method expects it.
|
|
385
|
+
if aggregate_class in _init_mentions_id:
|
|
386
|
+
init_kwargs["id"] = self_dict["originator_id"]
|
|
387
|
+
|
|
388
|
+
# Call the aggregate subclass class init method.
|
|
389
|
+
agg.__init__(**init_kwargs) # type: ignore[misc]
|
|
390
|
+
|
|
391
|
+
# Call the event apply method (alternative to using __init__())
|
|
392
|
+
self.apply(agg)
|
|
393
|
+
|
|
394
|
+
# Return the constructed and initialised aggregate object.
|
|
395
|
+
return agg
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class MetaDomainEvent(EventsourcingType):
|
|
399
|
+
"""Metaclass which ensures all domain event classes are frozen dataclasses."""
|
|
400
|
+
|
|
401
|
+
def __new__(
|
|
402
|
+
cls, name: str, bases: tuple[type[TDomainEvent], ...], cls_dict: dict[str, Any]
|
|
403
|
+
) -> type[TDomainEvent]:
|
|
404
|
+
event_cls = cast(
|
|
405
|
+
"type[TDomainEvent]", super().__new__(cls, name, bases, cls_dict)
|
|
406
|
+
)
|
|
407
|
+
event_cls = dataclasses.dataclass(frozen=True)(event_cls)
|
|
408
|
+
event_cls.__signature__ = inspect.signature(event_cls.__init__) # type: ignore[attr-defined]
|
|
409
|
+
return event_cls
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@dataclass(frozen=True)
|
|
413
|
+
class DomainEvent(CanCreateTimestamp, metaclass=MetaDomainEvent):
|
|
414
|
+
"""Frozen data class representing domain model events."""
|
|
415
|
+
|
|
416
|
+
originator_id: UUID
|
|
417
|
+
"""UUID identifying an aggregate to which the event belongs."""
|
|
418
|
+
originator_version: int
|
|
419
|
+
"""Integer identifying the version of the aggregate when the event occurred."""
|
|
420
|
+
timestamp: datetime
|
|
421
|
+
"""Timezone-aware :class:`datetime` object representing when an event occurred."""
|
|
422
|
+
|
|
423
|
+
def __post_init__(self) -> None:
|
|
424
|
+
if not isinstance(self.originator_id, UUID):
|
|
425
|
+
msg = (
|
|
426
|
+
f"{type(self)} "
|
|
427
|
+
f"was initialized with a non-UUID originator_id: "
|
|
428
|
+
f"{self.originator_id!r}"
|
|
429
|
+
)
|
|
430
|
+
raise TypeError(msg)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class AggregateEvent(CanMutateAggregate[UUID], DomainEvent):
|
|
434
|
+
"""Frozen data class representing aggregate events.
|
|
435
|
+
|
|
436
|
+
Subclasses represent original decisions made by domain model aggregates.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass(frozen=True)
|
|
441
|
+
class AggregateCreated(CanInitAggregate[UUID], AggregateEvent):
|
|
442
|
+
"""Frozen data class representing the initial creation of an aggregate."""
|
|
443
|
+
|
|
444
|
+
originator_topic: str
|
|
445
|
+
"""String describing the path to an aggregate class."""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class EventSourcingError(Exception):
|
|
449
|
+
"""Base exception class."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class ProgrammingError(EventSourcingError):
|
|
453
|
+
"""Exception class for domain model programming errors."""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class LogEvent(DomainEvent):
|
|
457
|
+
"""Deprecated: Inherit from DomainEvent instead.
|
|
458
|
+
|
|
459
|
+
Base class for the events of event-sourced logs.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def filter_kwargs_for_method_params(
|
|
464
|
+
kwargs: dict[str, Any], method: Callable[..., Any]
|
|
465
|
+
) -> dict[str, Any]:
|
|
466
|
+
names = _spec_filter_kwargs_for_method_params(method)
|
|
467
|
+
return {k: v for k, v in kwargs.items() if k in names}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@cache
|
|
471
|
+
def _spec_filter_kwargs_for_method_params(method: Callable[..., Any]) -> set[str]:
|
|
472
|
+
method_signature = inspect.signature(method)
|
|
473
|
+
return set(method_signature.parameters)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if TYPE_CHECKING:
|
|
477
|
+
EventSpecType = str | type[AbstractDecision]
|
|
478
|
+
|
|
479
|
+
CallableType = Callable[..., None]
|
|
480
|
+
DecoratableType = CallableType | property
|
|
481
|
+
TDecoratableType = TypeVar("TDecoratableType", bound=DecoratableType)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class CommandMethodDecorator:
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
event_spec: EventSpecType | None,
|
|
488
|
+
decorated_obj: DecoratableType,
|
|
489
|
+
event_topic: str | None = None,
|
|
490
|
+
):
|
|
491
|
+
self.is_name_inferred_from_method = False
|
|
492
|
+
self.given_event_cls: (
|
|
493
|
+
type[CanMutateAggregate[Any] | AbstractDecision] | None
|
|
494
|
+
) = None
|
|
495
|
+
self.event_cls_name: str | None = None
|
|
496
|
+
self.decorated_property: property | None = None
|
|
497
|
+
self.is_property_setter = False
|
|
498
|
+
self.property_setter_arg_name: str | None = None
|
|
499
|
+
self.decorated_func: CallableType
|
|
500
|
+
self.event_topic = event_topic
|
|
501
|
+
|
|
502
|
+
# Event name has been specified.
|
|
503
|
+
if isinstance(event_spec, str):
|
|
504
|
+
if event_spec == "":
|
|
505
|
+
msg = "Can't use empty string as name of event class"
|
|
506
|
+
raise ValueError(msg)
|
|
507
|
+
self.event_cls_name = event_spec
|
|
508
|
+
|
|
509
|
+
# Event class has been specified.
|
|
510
|
+
elif isinstance(event_spec, type) and issubclass(
|
|
511
|
+
event_spec, (CanMutateAggregate, AbstractDecision)
|
|
512
|
+
):
|
|
513
|
+
# Guard against associating more than one method body with any given class.
|
|
514
|
+
if (
|
|
515
|
+
issubclass(event_spec, CanMutateAggregate)
|
|
516
|
+
and event_spec in _given_event_classes
|
|
517
|
+
):
|
|
518
|
+
name = event_spec.__name__
|
|
519
|
+
msg = f"{name} event class used in more than one decorator"
|
|
520
|
+
raise TypeError(msg)
|
|
521
|
+
self.given_event_cls = event_spec
|
|
522
|
+
_given_event_classes.add(event_spec)
|
|
523
|
+
|
|
524
|
+
# Process a decorated property.
|
|
525
|
+
if isinstance(decorated_obj, property):
|
|
526
|
+
# Disallow putting event decorator on property getter.
|
|
527
|
+
if decorated_obj.fset is None:
|
|
528
|
+
assert decorated_obj.fget, "Property has no getter"
|
|
529
|
+
method_name = decorated_obj.fget.__name__
|
|
530
|
+
msg = f"@event can't decorate {method_name}() property getter"
|
|
531
|
+
raise TypeError(msg)
|
|
532
|
+
|
|
533
|
+
# Remember we are decorating a property.
|
|
534
|
+
self.decorated_property = decorated_obj
|
|
535
|
+
|
|
536
|
+
# TODO: Disallow unusual property setters in more detail.
|
|
537
|
+
assert isinstance(decorated_obj.fset, FunctionType)
|
|
538
|
+
|
|
539
|
+
# Disallow deriving event class names from property names.
|
|
540
|
+
if not self.given_event_cls and not self.event_cls_name:
|
|
541
|
+
method_name = decorated_obj.fset.__name__
|
|
542
|
+
msg = (
|
|
543
|
+
f"@event decorator on @{method_name}.setter "
|
|
544
|
+
f"requires event name or class"
|
|
545
|
+
)
|
|
546
|
+
raise TypeError(msg)
|
|
547
|
+
|
|
548
|
+
# Remember property "setter" as the decorated function.
|
|
549
|
+
self.decorated_func = decorated_obj.fset
|
|
550
|
+
|
|
551
|
+
# Remember the name of the second setter arg.
|
|
552
|
+
setter_arg_names = list(inspect.signature(self.decorated_func).parameters)
|
|
553
|
+
assert len(setter_arg_names) == 2
|
|
554
|
+
self.property_setter_arg_name = setter_arg_names[1]
|
|
555
|
+
|
|
556
|
+
# Process a decorated function.
|
|
557
|
+
elif isinstance(decorated_obj, FunctionType):
|
|
558
|
+
# Remember the decorated obj as the decorated method.
|
|
559
|
+
self.decorated_func = decorated_obj
|
|
560
|
+
|
|
561
|
+
all_func_decorators.append(self)
|
|
562
|
+
# If necessary, derive an event class name from the method.
|
|
563
|
+
if not self.given_event_cls and not self.event_cls_name:
|
|
564
|
+
original_method_name = self.decorated_func.__name__
|
|
565
|
+
if original_method_name != "__init__":
|
|
566
|
+
self.is_name_inferred_from_method = True
|
|
567
|
+
self.event_cls_name = "".join(
|
|
568
|
+
[s.capitalize() for s in original_method_name.split("_")]
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Disallow decorating other types of object.
|
|
572
|
+
else:
|
|
573
|
+
msg = f"{decorated_obj} is not a function or property"
|
|
574
|
+
raise TypeError(msg)
|
|
575
|
+
|
|
576
|
+
# Disallow using methods with variable params to define event class.
|
|
577
|
+
if self.event_cls_name:
|
|
578
|
+
_raise_type_error_if_func_has_variable_params(self.decorated_func)
|
|
579
|
+
|
|
580
|
+
# Disallow using methods with positional only params to define event class.
|
|
581
|
+
if self.event_cls_name:
|
|
582
|
+
_raise_type_error_if_func_has_positional_only_params(self.decorated_func)
|
|
583
|
+
|
|
584
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
|
585
|
+
# Initialised decorator was called directly, presumably by
|
|
586
|
+
# a decorating property that has this decorator as its fset.
|
|
587
|
+
# So trigger an event.
|
|
588
|
+
assert self.is_property_setter
|
|
589
|
+
assert self.property_setter_arg_name
|
|
590
|
+
assert len(args) == 2
|
|
591
|
+
assert len(kwargs) == 0
|
|
592
|
+
assert isinstance(args[0], Aggregate)
|
|
593
|
+
aggregate_instance = args[0]
|
|
594
|
+
bound = BoundCommandMethodDecorator(self, aggregate_instance)
|
|
595
|
+
property_setter_arg_value = args[1]
|
|
596
|
+
kwargs = {self.property_setter_arg_name: property_setter_arg_value}
|
|
597
|
+
bound.trigger(**kwargs)
|
|
598
|
+
|
|
599
|
+
@overload
|
|
600
|
+
def __get__(
|
|
601
|
+
self, instance: None, owner: type[BaseAggregate[Any]]
|
|
602
|
+
) -> UnboundCommandMethodDecorator | property:
|
|
603
|
+
"""
|
|
604
|
+
Descriptor protocol for getting decorated method or property on class object.
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
@overload
|
|
608
|
+
def __get__(
|
|
609
|
+
self, instance: BaseAggregate[Any], owner: type[BaseAggregate[Any]]
|
|
610
|
+
) -> BoundCommandMethodDecorator | Any:
|
|
611
|
+
"""
|
|
612
|
+
Descriptor protocol for getting decorated method or property on instance object.
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
def __get__(
|
|
616
|
+
self, instance: BaseAggregate[Any] | None, owner: type[BaseAggregate[Any]]
|
|
617
|
+
) -> BoundCommandMethodDecorator | UnboundCommandMethodDecorator | property | Any:
|
|
618
|
+
"""Descriptor protocol for getting decorated method or property."""
|
|
619
|
+
if self.decorated_func.__name__ == "_":
|
|
620
|
+
msg = "Underscore 'non-command' methods cannot be used to trigger events."
|
|
621
|
+
raise ProgrammingError(msg)
|
|
622
|
+
|
|
623
|
+
# If we are decorating a property, then delegate to the property's __get__.
|
|
624
|
+
if self.decorated_property:
|
|
625
|
+
return self.decorated_property.__get__(instance, owner)
|
|
626
|
+
|
|
627
|
+
# If we are decorating an __init__ method, then delegate to the __init__ method.
|
|
628
|
+
if self.decorated_func.__name__ == "__init__":
|
|
629
|
+
return self.decorated_func.__get__(instance, owner)
|
|
630
|
+
|
|
631
|
+
# Return a "bound" command method decorator if we have an instance.
|
|
632
|
+
if instance:
|
|
633
|
+
return BoundCommandMethodDecorator(self, instance)
|
|
634
|
+
|
|
635
|
+
if "SPHINX_BUILD" in os.environ: # pragma: no cover
|
|
636
|
+
# Sphinx hack: use the original function when sphinx is running so that the
|
|
637
|
+
# documentation ends up with the correct function signatures.
|
|
638
|
+
# See 'SPHINX_BUILD' in conf.py.
|
|
639
|
+
return self.decorated_func
|
|
640
|
+
|
|
641
|
+
# Return an "unbound" command method decorator if we have no instance.
|
|
642
|
+
return UnboundCommandMethodDecorator(self)
|
|
643
|
+
|
|
644
|
+
def __set__(self, instance: BaseAggregate[Any], value: Any) -> None:
|
|
645
|
+
"""Descriptor protocol for assigning to decorated property."""
|
|
646
|
+
# Set decorated property indirectly by triggering an event.
|
|
647
|
+
assert self.property_setter_arg_name
|
|
648
|
+
b = BoundCommandMethodDecorator(self, instance)
|
|
649
|
+
kwargs = {self.property_setter_arg_name: value}
|
|
650
|
+
b.trigger(**kwargs)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@overload
|
|
654
|
+
def event(arg: TDecoratableType, /) -> TDecoratableType:
|
|
655
|
+
"""Signature for calling ``@event`` decorator with decorated method."""
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@overload
|
|
659
|
+
def event(
|
|
660
|
+
arg: type[CanMutateAggregate[Any] | AbstractDecision], /
|
|
661
|
+
) -> Callable[[TDecoratableType], TDecoratableType]:
|
|
662
|
+
"""Signature for calling ``@event`` decorator with event class."""
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@overload
|
|
666
|
+
def event(
|
|
667
|
+
arg: str, /, *, topic: str | None = None
|
|
668
|
+
) -> Callable[[TDecoratableType], TDecoratableType]:
|
|
669
|
+
"""Signature for calling ``@event`` decorator with event name."""
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@overload
|
|
673
|
+
def event(arg: None = None, /) -> Callable[[TDecoratableType], TDecoratableType]:
|
|
674
|
+
"""Signature for calling ``@event`` decorator without event specification."""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def event(
|
|
678
|
+
arg: EventSpecType | TDecoratableType | None = None, /, *, topic: str | None = None
|
|
679
|
+
) -> TDecoratableType | Callable[[TDecoratableType], TDecoratableType]:
|
|
680
|
+
"""Event-triggering decorator for aggregate command methods and property setters.
|
|
681
|
+
|
|
682
|
+
Can be used to decorate an aggregate method or property setter so that an
|
|
683
|
+
event will be triggered when the method is called or the property is set.
|
|
684
|
+
The body of the method will be used to apply the event to the aggregate,
|
|
685
|
+
both when the event is triggered and when the aggregate is reconstructed
|
|
686
|
+
from stored events.
|
|
687
|
+
|
|
688
|
+
.. code-block:: python
|
|
689
|
+
|
|
690
|
+
class MyAggregate(Aggregate):
|
|
691
|
+
@event("NameChanged")
|
|
692
|
+
def set_name(self, name: str):
|
|
693
|
+
self.name = name
|
|
694
|
+
|
|
695
|
+
...is equivalent to...
|
|
696
|
+
|
|
697
|
+
.. code-block:: python
|
|
698
|
+
|
|
699
|
+
class MyAggregate(Aggregate):
|
|
700
|
+
def set_name(self, name: str):
|
|
701
|
+
self.trigger_event(self.NameChanged, name=name)
|
|
702
|
+
|
|
703
|
+
class NameChanged(Aggregate.Event):
|
|
704
|
+
name: str
|
|
705
|
+
|
|
706
|
+
def apply(self, aggregate):
|
|
707
|
+
aggregate.name = self.name
|
|
708
|
+
|
|
709
|
+
In the example above, the event "NameChanged" is defined automatically
|
|
710
|
+
by inspecting the signature of the ``set_name()`` method. If it is
|
|
711
|
+
preferred to declare the event class explicitly, for example to define
|
|
712
|
+
upcasting of old events, the event class itself can be mentioned in the
|
|
713
|
+
event decorator rather than just providing the name of the event as a
|
|
714
|
+
string.
|
|
715
|
+
|
|
716
|
+
.. code-block:: python
|
|
717
|
+
|
|
718
|
+
class MyAggregate(Aggregate):
|
|
719
|
+
|
|
720
|
+
class NameChanged(Aggregate.Event):
|
|
721
|
+
name: str
|
|
722
|
+
|
|
723
|
+
@event(NameChanged)
|
|
724
|
+
def set_name(self, name: str):
|
|
725
|
+
aggregate.name = self.name
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
"""
|
|
729
|
+
if isinstance(arg, (FunctionType, property)):
|
|
730
|
+
command_method_decorator = CommandMethodDecorator(
|
|
731
|
+
event_spec=None,
|
|
732
|
+
decorated_obj=arg,
|
|
733
|
+
)
|
|
734
|
+
return cast(
|
|
735
|
+
"Callable[[TDecoratableType], TDecoratableType]", command_method_decorator
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if (
|
|
739
|
+
arg is None
|
|
740
|
+
or isinstance(arg, str)
|
|
741
|
+
or (
|
|
742
|
+
isinstance(arg, type)
|
|
743
|
+
and issubclass(arg, (CanMutateAggregate, AbstractDecision))
|
|
744
|
+
)
|
|
745
|
+
):
|
|
746
|
+
event_spec = arg
|
|
747
|
+
|
|
748
|
+
def create_command_method_decorator(
|
|
749
|
+
decorated_obj: TDecoratableType,
|
|
750
|
+
) -> TDecoratableType:
|
|
751
|
+
command_method_decorator = CommandMethodDecorator(
|
|
752
|
+
event_spec=event_spec,
|
|
753
|
+
decorated_obj=decorated_obj,
|
|
754
|
+
event_topic=topic,
|
|
755
|
+
)
|
|
756
|
+
return cast("TDecoratableType", command_method_decorator)
|
|
757
|
+
|
|
758
|
+
return create_command_method_decorator
|
|
759
|
+
|
|
760
|
+
msg = (
|
|
761
|
+
f"{arg} is not a str, function, property, or subclass of "
|
|
762
|
+
f"{CanMutateAggregate.__name__}"
|
|
763
|
+
)
|
|
764
|
+
raise TypeError(msg)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
triggers = event
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class UnboundCommandMethodDecorator:
|
|
771
|
+
"""Wraps a CommandMethodDecorator instance when accessed on an aggregate class."""
|
|
772
|
+
|
|
773
|
+
def __init__(self, event_decorator: CommandMethodDecorator):
|
|
774
|
+
""":param CommandMethodDecorator event_decorator:"""
|
|
775
|
+
self.event_decorator = event_decorator
|
|
776
|
+
self.__module__ = event_decorator.decorated_func.__module__
|
|
777
|
+
self.__name__ = event_decorator.decorated_func.__name__
|
|
778
|
+
self.__qualname__ = event_decorator.decorated_func.__qualname__
|
|
779
|
+
self.__annotations__ = event_decorator.decorated_func.__annotations__
|
|
780
|
+
self.__doc__ = event_decorator.decorated_func.__doc__
|
|
781
|
+
# self.__wrapped__ = event_decorator.decorated_method
|
|
782
|
+
# functools.update_wrapper(self, event_decorator.decorated_method)
|
|
783
|
+
|
|
784
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
|
785
|
+
# TODO: Review this, because other subclasses of BaseAggregate might too....
|
|
786
|
+
# Expect first argument is an aggregate instance.
|
|
787
|
+
if len(args) < 1 or not isinstance(args[0], Aggregate):
|
|
788
|
+
msg = "Expected aggregate as first argument"
|
|
789
|
+
raise TypeError(msg)
|
|
790
|
+
aggregate: Aggregate = args[0]
|
|
791
|
+
assert isinstance(aggregate, Aggregate)
|
|
792
|
+
BoundCommandMethodDecorator(self.event_decorator, aggregate)(
|
|
793
|
+
*args[1:], **kwargs
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
class CanTriggerEvent(Protocol):
|
|
798
|
+
def trigger_event(
|
|
799
|
+
self,
|
|
800
|
+
event_class: type[Any],
|
|
801
|
+
**kwargs: Any,
|
|
802
|
+
) -> None:
|
|
803
|
+
pass # pragma: no cover
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class BoundCommandMethodDecorator:
|
|
807
|
+
"""Binds a CommandMethodDecorator with an object instance that can trigger
|
|
808
|
+
events, so that calls to decorated command methods can be intercepted and
|
|
809
|
+
will trigger a "decorated func caller" event.
|
|
810
|
+
"""
|
|
811
|
+
|
|
812
|
+
def __init__(self, event_decorator: CommandMethodDecorator, obj: CanTriggerEvent):
|
|
813
|
+
""":param CommandMethodDecorator event_decorator:
|
|
814
|
+
:param Aggregate aggregate:
|
|
815
|
+
"""
|
|
816
|
+
self.event_decorator = event_decorator
|
|
817
|
+
self.__module__ = event_decorator.decorated_func.__module__
|
|
818
|
+
self.__name__ = event_decorator.decorated_func.__name__
|
|
819
|
+
self.__qualname__ = event_decorator.decorated_func.__qualname__
|
|
820
|
+
self.__annotations__ = event_decorator.decorated_func.__annotations__
|
|
821
|
+
self.__doc__ = event_decorator.decorated_func.__doc__
|
|
822
|
+
self.obj = obj
|
|
823
|
+
|
|
824
|
+
def trigger(self, *args: Any, **kwargs: Any) -> None:
|
|
825
|
+
kwargs = _coerce_args_to_kwargs(
|
|
826
|
+
self.event_decorator.decorated_func, args, kwargs
|
|
827
|
+
)
|
|
828
|
+
try:
|
|
829
|
+
event_cls = decorated_func_callers[self.event_decorator]
|
|
830
|
+
except KeyError as e: # pragma: no cover
|
|
831
|
+
msg = (
|
|
832
|
+
f"Event class not registered for event decorator on "
|
|
833
|
+
f"{self.event_decorator.decorated_func.__qualname__}"
|
|
834
|
+
)
|
|
835
|
+
raise KeyError(msg) from e
|
|
836
|
+
kwargs = filter_kwargs_for_method_params(kwargs, event_cls)
|
|
837
|
+
self.obj.trigger_event(event_cls, **kwargs)
|
|
838
|
+
|
|
839
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
|
840
|
+
self.trigger(*args, **kwargs)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
class AbstractDecoratedFuncCaller:
|
|
844
|
+
pass
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class DecoratedFuncCaller(CanMutateAggregate[Any], AbstractDecoratedFuncCaller):
|
|
848
|
+
def apply(self, aggregate: BaseAggregate[Any]) -> None:
|
|
849
|
+
"""Applies event to aggregate by calling method decorated by @event."""
|
|
850
|
+
# Identify the function that was decorated.
|
|
851
|
+
decorated_func = decorated_funcs[type(self)]
|
|
852
|
+
|
|
853
|
+
# Select event attributes mentioned in function signature.
|
|
854
|
+
self_dict = self._as_dict()
|
|
855
|
+
kwargs = filter_kwargs_for_method_params(self_dict, decorated_func)
|
|
856
|
+
|
|
857
|
+
# Call the original method with event attribute values.
|
|
858
|
+
decorated_method = decorated_func.__get__(aggregate, type(aggregate))
|
|
859
|
+
decorated_method(**kwargs)
|
|
860
|
+
|
|
861
|
+
# Call super method, just in case any base classes need it.
|
|
862
|
+
super().apply(aggregate)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# This helps enforce single usage of original event classes in decorators.
|
|
866
|
+
_given_event_classes = set[type]()
|
|
867
|
+
|
|
868
|
+
# This keeps track of the "created" event classes for an aggregate.
|
|
869
|
+
_created_event_classes: dict[type, list[type[CanInitAggregate[Any]]]] = {}
|
|
870
|
+
|
|
871
|
+
# This remembers which event class to trigger when a decorated method is called.
|
|
872
|
+
decorated_func_callers: dict[CommandMethodDecorator, type[AbstractDecision]] = {}
|
|
873
|
+
|
|
874
|
+
# This remembers which decorated func a decorated func caller should call.
|
|
875
|
+
decorated_funcs: dict[type, CallableType] = {}
|
|
876
|
+
|
|
877
|
+
# This keeps track of decorators on "non-command" projection-only methods.
|
|
878
|
+
all_func_decorators: list[CommandMethodDecorator] = []
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _raise_type_error_if_func_has_variable_params(method: CallableType) -> None:
|
|
882
|
+
for param in inspect.signature(method).parameters.values():
|
|
883
|
+
if param.kind is param.VAR_POSITIONAL:
|
|
884
|
+
msg = f"*{param.name} not supported by decorator on {method.__name__}()"
|
|
885
|
+
raise TypeError(msg)
|
|
886
|
+
# TODO: Support VAR_POSITIONAL?
|
|
887
|
+
# annotations["__star_args__"] = "typing.Any"
|
|
888
|
+
|
|
889
|
+
if param.kind is param.VAR_KEYWORD:
|
|
890
|
+
# TODO: Support VAR_KEYWORD?
|
|
891
|
+
# annotations["__star_kwargs__"] = "typing.Any"
|
|
892
|
+
msg = f"**{param.name} not supported by decorator on {method.__name__}()"
|
|
893
|
+
raise TypeError(msg)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _raise_type_error_if_func_has_positional_only_params(method: CallableType) -> None:
|
|
897
|
+
# TODO: Support POSITIONAL_ONLY?
|
|
898
|
+
positional_only_params = []
|
|
899
|
+
for param in inspect.signature(method).parameters.values():
|
|
900
|
+
if param.name == "self":
|
|
901
|
+
continue
|
|
902
|
+
if param.kind is param.POSITIONAL_ONLY:
|
|
903
|
+
positional_only_params.append(param.name)
|
|
904
|
+
|
|
905
|
+
if positional_only_params:
|
|
906
|
+
msg = (
|
|
907
|
+
f"positional only args arg not supported by @event decorator on "
|
|
908
|
+
f"{method.__name__}(): {', '.join(positional_only_params)}"
|
|
909
|
+
)
|
|
910
|
+
raise TypeError(msg)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _coerce_args_to_kwargs(
|
|
914
|
+
method: CallableType,
|
|
915
|
+
args: Iterable[Any],
|
|
916
|
+
kwargs: dict[str, Any],
|
|
917
|
+
*,
|
|
918
|
+
expects_id: bool = False,
|
|
919
|
+
) -> dict[str, Any]:
|
|
920
|
+
# __init__ methods are WrapperDescriptorType, other method are FunctionType.
|
|
921
|
+
assert isinstance(method, (FunctionType, WrapperDescriptorType)), method
|
|
922
|
+
|
|
923
|
+
args = tuple(args)
|
|
924
|
+
enumerated_args_names, keyword_defaults_items = _spec_coerce_args_to_kwargs(
|
|
925
|
+
method=method,
|
|
926
|
+
len_args=len(args),
|
|
927
|
+
kwargs_keys=tuple(kwargs.keys()),
|
|
928
|
+
expects_id=expects_id,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
copy_kwargs = dict(kwargs)
|
|
932
|
+
copy_kwargs.update({name: args[i] for i, name in enumerated_args_names})
|
|
933
|
+
copy_kwargs.update(keyword_defaults_items)
|
|
934
|
+
return copy_kwargs
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@cache
|
|
938
|
+
def _spec_coerce_args_to_kwargs(
|
|
939
|
+
method: CallableType,
|
|
940
|
+
len_args: int,
|
|
941
|
+
kwargs_keys: tuple[str],
|
|
942
|
+
*,
|
|
943
|
+
expects_id: bool,
|
|
944
|
+
) -> tuple[tuple[tuple[int, str], ...], tuple[tuple[str, Any], ...]]:
|
|
945
|
+
method_signature = inspect.signature(method)
|
|
946
|
+
positional_names = []
|
|
947
|
+
keyword_defaults = {}
|
|
948
|
+
required_positional = []
|
|
949
|
+
required_keyword_only = []
|
|
950
|
+
if expects_id:
|
|
951
|
+
positional_names.append("id")
|
|
952
|
+
required_positional.append("id")
|
|
953
|
+
for name, param in method_signature.parameters.items():
|
|
954
|
+
if name == "self":
|
|
955
|
+
continue
|
|
956
|
+
# elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD):
|
|
957
|
+
if param.kind is param.KEYWORD_ONLY:
|
|
958
|
+
required_keyword_only.append(name)
|
|
959
|
+
if param.kind is param.POSITIONAL_OR_KEYWORD:
|
|
960
|
+
positional_names.append(name)
|
|
961
|
+
if param.default == param.empty:
|
|
962
|
+
required_positional.append(name)
|
|
963
|
+
if param.default != param.empty:
|
|
964
|
+
keyword_defaults[name] = param.default
|
|
965
|
+
# if not required_keyword_only and not positional_names:
|
|
966
|
+
# if args or kwargs:
|
|
967
|
+
# raise TypeError(f"{method.__name__}() takes no args")
|
|
968
|
+
method_name = get_method_name(method)
|
|
969
|
+
for name in kwargs_keys:
|
|
970
|
+
if name not in required_keyword_only and name not in positional_names:
|
|
971
|
+
msg = f"{method_name}() got an unexpected keyword argument '{name}'"
|
|
972
|
+
raise TypeError(msg)
|
|
973
|
+
if len_args > len(positional_names):
|
|
974
|
+
msg = (
|
|
975
|
+
f"{method_name}() takes {len(positional_names) + 1} "
|
|
976
|
+
f"positional argument{'' if len(positional_names) + 1 == 1 else 's'} "
|
|
977
|
+
f"but {len_args + 1} were given"
|
|
978
|
+
)
|
|
979
|
+
raise TypeError(msg)
|
|
980
|
+
required_positional_not_in_kwargs = [
|
|
981
|
+
n for n in required_positional if n not in kwargs_keys
|
|
982
|
+
]
|
|
983
|
+
num_missing = len(required_positional_not_in_kwargs) - len_args
|
|
984
|
+
if num_missing > 0:
|
|
985
|
+
missing_names = [
|
|
986
|
+
f"'{name}'" for name in required_positional_not_in_kwargs[len_args:]
|
|
987
|
+
]
|
|
988
|
+
msg = (
|
|
989
|
+
f"{method_name}() missing {num_missing} required positional "
|
|
990
|
+
f"argument{'' if num_missing == 1 else 's'}: "
|
|
991
|
+
)
|
|
992
|
+
_raise_missing_names_type_error(missing_names, msg)
|
|
993
|
+
args_names = []
|
|
994
|
+
for counter, name in enumerate(positional_names):
|
|
995
|
+
if counter + 1 > len_args:
|
|
996
|
+
break
|
|
997
|
+
if name in kwargs_keys:
|
|
998
|
+
msg = f"{method_name}() got multiple values for argument '{name}'"
|
|
999
|
+
raise TypeError(msg)
|
|
1000
|
+
args_names.append(name)
|
|
1001
|
+
missing_keyword_only_arguments = [
|
|
1002
|
+
name for name in required_keyword_only if name not in kwargs_keys
|
|
1003
|
+
]
|
|
1004
|
+
if missing_keyword_only_arguments:
|
|
1005
|
+
missing_names = [f"'{name}'" for name in missing_keyword_only_arguments]
|
|
1006
|
+
msg = (
|
|
1007
|
+
f"{method_name}() missing {len(missing_names)} "
|
|
1008
|
+
"required keyword-only argument"
|
|
1009
|
+
f"{'' if len(missing_names) == 1 else 's'}: "
|
|
1010
|
+
)
|
|
1011
|
+
_raise_missing_names_type_error(missing_names, msg)
|
|
1012
|
+
for key in tuple(keyword_defaults.keys()):
|
|
1013
|
+
if key in args_names or key in kwargs_keys:
|
|
1014
|
+
keyword_defaults.pop(key)
|
|
1015
|
+
enumerated_args_names = tuple(enumerate(args_names))
|
|
1016
|
+
keyword_defaults_items = tuple(keyword_defaults.items())
|
|
1017
|
+
return enumerated_args_names, keyword_defaults_items
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _raise_missing_names_type_error(missing_names: list[str], msg: str) -> None:
|
|
1021
|
+
msg += missing_names[0]
|
|
1022
|
+
if len(missing_names) == 2:
|
|
1023
|
+
msg += f" and {missing_names[1]}"
|
|
1024
|
+
elif len(missing_names) > 2:
|
|
1025
|
+
msg += ", " + ", ".join(missing_names[1:-1])
|
|
1026
|
+
msg += f", and {missing_names[-1]}"
|
|
1027
|
+
raise TypeError(msg)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
_annotations_mention_id: set[type[BaseAggregate[Any]]] = set()
|
|
1031
|
+
_init_mentions_id: set[type[BaseAggregate[Any]]] = set()
|
|
1032
|
+
_create_id_param_names: dict[type[BaseAggregate[Any]], list[str]] = defaultdict(list)
|
|
1033
|
+
|
|
1034
|
+
ENVVAR_DISABLE_REDEFINITION_CHECK = "EVENTSOURCING_DISABLE_REDEFINITION_CHECK"
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class MetaAggregate(EventsourcingType, ABCMeta, Generic[TAggregate]):
|
|
1038
|
+
"""Metaclass for aggregate classes."""
|
|
1039
|
+
|
|
1040
|
+
def _define_event_class(
|
|
1041
|
+
cls,
|
|
1042
|
+
name: str,
|
|
1043
|
+
bases: tuple[type[CanMutateAggregate[Any]], ...],
|
|
1044
|
+
apply_method: CallableType | None,
|
|
1045
|
+
event_topic: str | None = None,
|
|
1046
|
+
) -> type[CanMutateAggregate[Any]]:
|
|
1047
|
+
# Define annotations for the event class (specs the init method).
|
|
1048
|
+
annotations = {}
|
|
1049
|
+
if apply_method is not None:
|
|
1050
|
+
method_signature = inspect.signature(apply_method)
|
|
1051
|
+
supers = {
|
|
1052
|
+
s for b in bases for s in b.__mro__ if hasattr(s, "__annotations__")
|
|
1053
|
+
}
|
|
1054
|
+
super_annotations = {a for s in supers for a in s.__annotations__}
|
|
1055
|
+
for param_name, param in list(method_signature.parameters.items())[1:]:
|
|
1056
|
+
# Don't define 'id' on a "created" class.
|
|
1057
|
+
if param_name == "id" and apply_method.__name__ == "__init__":
|
|
1058
|
+
continue
|
|
1059
|
+
# Don't override super class annotations, unless no default on param.
|
|
1060
|
+
if param_name not in super_annotations or param.default == param.empty:
|
|
1061
|
+
annotations[param_name] = param.annotation or "typing.Any"
|
|
1062
|
+
event_cls_qualname = f"{cls.__qualname__}.{name}"
|
|
1063
|
+
event_cls_dict = {
|
|
1064
|
+
"__annotations__": annotations,
|
|
1065
|
+
"__module__": cls.__module__,
|
|
1066
|
+
"__qualname__": event_cls_qualname,
|
|
1067
|
+
}
|
|
1068
|
+
if event_topic:
|
|
1069
|
+
event_cls_dict["TOPIC"] = event_topic
|
|
1070
|
+
|
|
1071
|
+
# Create the event class object.
|
|
1072
|
+
_new_class = type(name, bases, event_cls_dict)
|
|
1073
|
+
return cast("type[CanMutateAggregate[Any]]", _new_class)
|
|
1074
|
+
|
|
1075
|
+
def __call__(
|
|
1076
|
+
cls: MetaAggregate[TAggregate], *args: Any, **kwargs: Any
|
|
1077
|
+
) -> TAggregate:
|
|
1078
|
+
if cls is BaseAggregate:
|
|
1079
|
+
msg = "Please define or use subclasses of BaseAggregate."
|
|
1080
|
+
raise TypeError(msg)
|
|
1081
|
+
created_event_classes = _created_event_classes[cls]
|
|
1082
|
+
# Here, unlike when calling _create(), we don't have a given event class,
|
|
1083
|
+
# so we need to check that there is one "created" event class to use here.
|
|
1084
|
+
# We don't check this in __init_subclass__ to allow for alternatives that
|
|
1085
|
+
# can be selected by developers by calling _create(event_class=...).
|
|
1086
|
+
if len(created_event_classes) == 0:
|
|
1087
|
+
msg = f"No \"created\" event classes defined on class '{cls.__name__}'."
|
|
1088
|
+
raise TypeError(msg)
|
|
1089
|
+
|
|
1090
|
+
if len(created_event_classes) > 1:
|
|
1091
|
+
msg = (
|
|
1092
|
+
f"{cls.__qualname__} can't decide which of many "
|
|
1093
|
+
'"created" event classes to use: '
|
|
1094
|
+
f"""'{"', '".join(c.__name__ for c in created_event_classes)}'. """
|
|
1095
|
+
"Please use class arg 'created_event_name' or"
|
|
1096
|
+
" @event decorator on __init__ method."
|
|
1097
|
+
)
|
|
1098
|
+
raise TypeError(msg)
|
|
1099
|
+
|
|
1100
|
+
kwargs = _coerce_args_to_kwargs(
|
|
1101
|
+
cls.__init__, # type: ignore[misc]
|
|
1102
|
+
args,
|
|
1103
|
+
kwargs,
|
|
1104
|
+
expects_id=cls in _annotations_mention_id,
|
|
1105
|
+
)
|
|
1106
|
+
return cls._create(
|
|
1107
|
+
event_class=created_event_classes[0],
|
|
1108
|
+
**kwargs,
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
def _create(
|
|
1112
|
+
cls: MetaAggregate[TAggregate],
|
|
1113
|
+
event_class: type[CanInitAggregate[Any]],
|
|
1114
|
+
**kwargs: Any,
|
|
1115
|
+
) -> TAggregate:
|
|
1116
|
+
# Just define method signature for the __call__() method.
|
|
1117
|
+
raise NotImplementedError # pragma: no cover
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class BaseAggregate(Generic[TAggregateID], metaclass=MetaAggregate):
|
|
1121
|
+
"""Base class for aggregates."""
|
|
1122
|
+
|
|
1123
|
+
INITIAL_VERSION: int = 1
|
|
1124
|
+
|
|
1125
|
+
@staticmethod
|
|
1126
|
+
def create_id(*_: Any, **__: Any) -> TAggregateID:
|
|
1127
|
+
"""Returns a new aggregate ID."""
|
|
1128
|
+
raise NotImplementedError
|
|
1129
|
+
|
|
1130
|
+
@classmethod
|
|
1131
|
+
def _create(
|
|
1132
|
+
cls: type[Self],
|
|
1133
|
+
event_class: type[CanInitAggregate[TAggregateID]],
|
|
1134
|
+
*,
|
|
1135
|
+
id: TAggregateID | None = None, # noqa: A002
|
|
1136
|
+
**kwargs: Any,
|
|
1137
|
+
) -> Self:
|
|
1138
|
+
"""Constructs a new aggregate object instance."""
|
|
1139
|
+
if getattr(cls, "TOPIC", None):
|
|
1140
|
+
_check_explicit_topic_is_registered(event_class)
|
|
1141
|
+
|
|
1142
|
+
# Construct the domain event with an ID and a
|
|
1143
|
+
# version, and a topic for the aggregate class.
|
|
1144
|
+
create_id_kwargs = {
|
|
1145
|
+
k: v for k, v in kwargs.items() if k in _create_id_param_names[cls]
|
|
1146
|
+
}
|
|
1147
|
+
if id is not None:
|
|
1148
|
+
originator_id = id
|
|
1149
|
+
if not isinstance(originator_id, (UUID, str)):
|
|
1150
|
+
msg = f"Given id was not a UUID or str: {originator_id!r}"
|
|
1151
|
+
raise TypeError(msg)
|
|
1152
|
+
else:
|
|
1153
|
+
try:
|
|
1154
|
+
originator_id = cls.create_id(**create_id_kwargs)
|
|
1155
|
+
except NotImplementedError as e:
|
|
1156
|
+
msg = f"Please pass an 'id' arg or define a create_id() method on {cls}"
|
|
1157
|
+
raise NotImplementedError(msg) from e
|
|
1158
|
+
|
|
1159
|
+
if not isinstance(originator_id, (UUID, str)):
|
|
1160
|
+
msg = (
|
|
1161
|
+
f"{cls.create_id.__module__}.{cls.create_id.__qualname__}"
|
|
1162
|
+
f" did not return UUID or str, it returned: {originator_id!r}"
|
|
1163
|
+
)
|
|
1164
|
+
raise TypeError(msg)
|
|
1165
|
+
|
|
1166
|
+
# Impose the required common "created" event attribute values.
|
|
1167
|
+
kwargs = kwargs.copy()
|
|
1168
|
+
kwargs.update(
|
|
1169
|
+
originator_topic=get_topic(cls),
|
|
1170
|
+
originator_id=originator_id,
|
|
1171
|
+
originator_version=cls.INITIAL_VERSION,
|
|
1172
|
+
)
|
|
1173
|
+
if kwargs.get("timestamp") is None:
|
|
1174
|
+
kwargs["timestamp"] = event_class.create_timestamp()
|
|
1175
|
+
|
|
1176
|
+
try:
|
|
1177
|
+
created_event = event_class(**kwargs)
|
|
1178
|
+
except TypeError as e:
|
|
1179
|
+
msg = f"Unable to construct '{event_class.__qualname__}' event: {e}"
|
|
1180
|
+
raise TypeError(msg) from e
|
|
1181
|
+
# Construct the aggregate object.
|
|
1182
|
+
agg = cast("Self", created_event.mutate(None))
|
|
1183
|
+
|
|
1184
|
+
assert agg is not None
|
|
1185
|
+
# Append the domain event to pending list.
|
|
1186
|
+
agg.pending_events.append(created_event)
|
|
1187
|
+
# Return the aggregate.
|
|
1188
|
+
return agg
|
|
1189
|
+
|
|
1190
|
+
def __base_init__(
|
|
1191
|
+
self,
|
|
1192
|
+
originator_id: Any,
|
|
1193
|
+
originator_version: int,
|
|
1194
|
+
timestamp: datetime,
|
|
1195
|
+
) -> None:
|
|
1196
|
+
"""Initialises an aggregate object with an :data:`id`, a :data:`version`
|
|
1197
|
+
number, and a :data:`timestamp`.
|
|
1198
|
+
"""
|
|
1199
|
+
self._id: TAggregateID = originator_id
|
|
1200
|
+
self._version = originator_version
|
|
1201
|
+
self._created_on = timestamp
|
|
1202
|
+
self._modified_on = timestamp
|
|
1203
|
+
self._pending_events: list[CanMutateAggregate[TAggregateID]] = []
|
|
1204
|
+
|
|
1205
|
+
@property
|
|
1206
|
+
def id(self) -> TAggregateID:
|
|
1207
|
+
"""The ID of the aggregate."""
|
|
1208
|
+
return self._id
|
|
1209
|
+
|
|
1210
|
+
@property
|
|
1211
|
+
def version(self) -> int:
|
|
1212
|
+
"""The version number of the aggregate."""
|
|
1213
|
+
return self._version
|
|
1214
|
+
|
|
1215
|
+
@version.setter
|
|
1216
|
+
def version(self, version: int) -> None:
|
|
1217
|
+
self._version = version
|
|
1218
|
+
|
|
1219
|
+
@property
|
|
1220
|
+
def created_on(self) -> datetime:
|
|
1221
|
+
"""The date and time when the aggregate was created."""
|
|
1222
|
+
return self._created_on
|
|
1223
|
+
|
|
1224
|
+
@property
|
|
1225
|
+
def modified_on(self) -> datetime:
|
|
1226
|
+
"""The date and time when the aggregate was last modified."""
|
|
1227
|
+
return self._modified_on
|
|
1228
|
+
|
|
1229
|
+
@modified_on.setter
|
|
1230
|
+
def modified_on(self, modified_on: datetime) -> None:
|
|
1231
|
+
self._modified_on = modified_on
|
|
1232
|
+
|
|
1233
|
+
@property
|
|
1234
|
+
def pending_events(self) -> list[CanMutateAggregate[TAggregateID]]:
|
|
1235
|
+
"""A list of pending events."""
|
|
1236
|
+
return self._pending_events
|
|
1237
|
+
|
|
1238
|
+
def trigger_event(
|
|
1239
|
+
self,
|
|
1240
|
+
event_class: type[CanMutateAggregate[TAggregateID]],
|
|
1241
|
+
**kwargs: Any,
|
|
1242
|
+
) -> None:
|
|
1243
|
+
"""Triggers domain event of given type, by creating
|
|
1244
|
+
an event object and using it to mutate the aggregate.
|
|
1245
|
+
"""
|
|
1246
|
+
if getattr(type(self), "TOPIC", None):
|
|
1247
|
+
if event_class.__name__ == "Event":
|
|
1248
|
+
msg = "Triggering base 'Event' class is prohibited."
|
|
1249
|
+
raise ProgrammingError(msg)
|
|
1250
|
+
_check_explicit_topic_is_registered(event_class)
|
|
1251
|
+
|
|
1252
|
+
# Construct the domain event as the
|
|
1253
|
+
# next in the aggregate's sequence.
|
|
1254
|
+
# Use counting to generate the sequence.
|
|
1255
|
+
next_version = self.version + 1
|
|
1256
|
+
|
|
1257
|
+
# Impose the required common domain event attribute values.
|
|
1258
|
+
kwargs = kwargs.copy()
|
|
1259
|
+
kwargs.update(
|
|
1260
|
+
originator_id=self.id,
|
|
1261
|
+
originator_version=next_version,
|
|
1262
|
+
)
|
|
1263
|
+
if kwargs.get("timestamp") is None:
|
|
1264
|
+
kwargs["timestamp"] = event_class.create_timestamp()
|
|
1265
|
+
|
|
1266
|
+
try:
|
|
1267
|
+
new_event = event_class(**kwargs)
|
|
1268
|
+
except TypeError as e:
|
|
1269
|
+
msg = f"Can't construct event {event_class}: {e}"
|
|
1270
|
+
raise TypeError(msg) from None
|
|
1271
|
+
|
|
1272
|
+
# Mutate aggregate with domain event.
|
|
1273
|
+
new_event.mutate(self)
|
|
1274
|
+
# Append the domain event to pending list.
|
|
1275
|
+
self._pending_events.append(new_event)
|
|
1276
|
+
|
|
1277
|
+
def collect_events(self) -> Sequence[CanMutateAggregate[TAggregateID]]:
|
|
1278
|
+
"""Collects and returns a list of pending aggregate
|
|
1279
|
+
:class:`AggregateEvent` objects.
|
|
1280
|
+
"""
|
|
1281
|
+
collected = []
|
|
1282
|
+
while self._pending_events:
|
|
1283
|
+
collected.append(self._pending_events.pop(0))
|
|
1284
|
+
return collected
|
|
1285
|
+
|
|
1286
|
+
def __eq__(self, other: object) -> bool:
|
|
1287
|
+
return type(self) is type(other) and self.__dict__ == other.__dict__
|
|
1288
|
+
|
|
1289
|
+
def __repr__(self) -> str:
|
|
1290
|
+
attrs = [
|
|
1291
|
+
f"{k.lstrip('_')}={v!r}"
|
|
1292
|
+
for k, v in self.__dict__.items()
|
|
1293
|
+
if k != "_pending_events"
|
|
1294
|
+
]
|
|
1295
|
+
return f"{type(self).__name__}({', '.join(attrs)})"
|
|
1296
|
+
|
|
1297
|
+
def __init_subclass__(
|
|
1298
|
+
cls: type[BaseAggregate[TAggregateID]], *, created_event_name: str = ""
|
|
1299
|
+
) -> None:
|
|
1300
|
+
"""
|
|
1301
|
+
Initialises aggregate subclass by defining __init__ method and event classes.
|
|
1302
|
+
"""
|
|
1303
|
+
super().__init_subclass__()
|
|
1304
|
+
|
|
1305
|
+
# Ensure we aren't defining another instance of the same class,
|
|
1306
|
+
# because annotations can get confused when using singledispatchmethod
|
|
1307
|
+
# during class definition e.g. on an aggregate projector function.
|
|
1308
|
+
_module = importlib.import_module(cls.__module__)
|
|
1309
|
+
if (
|
|
1310
|
+
cls.__name__ in _module.__dict__
|
|
1311
|
+
and ENVVAR_DISABLE_REDEFINITION_CHECK not in os.environ
|
|
1312
|
+
):
|
|
1313
|
+
msg = (
|
|
1314
|
+
f"Name '{cls.__name__}' of {cls} already defined in "
|
|
1315
|
+
f"'{cls.__module__}' module: {_module.__dict__[cls.__name__]}"
|
|
1316
|
+
)
|
|
1317
|
+
raise ProgrammingError(msg)
|
|
1318
|
+
|
|
1319
|
+
# Get the class annotations.
|
|
1320
|
+
class_annotations = cls.__dict__.get("__annotations__", {})
|
|
1321
|
+
try:
|
|
1322
|
+
class_annotations.pop("id")
|
|
1323
|
+
_annotations_mention_id.add(cls)
|
|
1324
|
+
except KeyError:
|
|
1325
|
+
pass
|
|
1326
|
+
|
|
1327
|
+
if "id" in cls.__dict__:
|
|
1328
|
+
msg = f"Setting attribute 'id' on class '{cls.__name__}' is not allowed"
|
|
1329
|
+
raise ProgrammingError(msg)
|
|
1330
|
+
|
|
1331
|
+
# Process the class as a dataclass, if there are annotations.
|
|
1332
|
+
if (
|
|
1333
|
+
class_annotations
|
|
1334
|
+
or cls in _annotations_mention_id
|
|
1335
|
+
or any(dataclasses.is_dataclass(base) for base in cls.__bases__)
|
|
1336
|
+
):
|
|
1337
|
+
dataclasses.dataclass(eq=False, repr=False)(cls)
|
|
1338
|
+
|
|
1339
|
+
# Remember if __init__ mentions ID.
|
|
1340
|
+
for param_name in inspect.signature(cls.__init__).parameters:
|
|
1341
|
+
if param_name == "id":
|
|
1342
|
+
_init_mentions_id.add(cls)
|
|
1343
|
+
break
|
|
1344
|
+
|
|
1345
|
+
# Analyse __init__ attribute, to get __init__ method and @event decorator.
|
|
1346
|
+
init_attr: FunctionType | CommandMethodDecorator | None = cls.__dict__.get(
|
|
1347
|
+
"__init__"
|
|
1348
|
+
)
|
|
1349
|
+
if init_attr is None:
|
|
1350
|
+
# No method, no decorator.
|
|
1351
|
+
init_method: CallableType | None = None
|
|
1352
|
+
init_decorator: CommandMethodDecorator | None = None
|
|
1353
|
+
elif isinstance(init_attr, CommandMethodDecorator):
|
|
1354
|
+
# Method decorated with @event.
|
|
1355
|
+
init_method = init_attr.decorated_func
|
|
1356
|
+
init_decorator = init_attr
|
|
1357
|
+
else:
|
|
1358
|
+
# Undecorated __init__ method.
|
|
1359
|
+
init_decorator = None
|
|
1360
|
+
init_method = init_attr
|
|
1361
|
+
|
|
1362
|
+
# Identify or define a base event class for this aggregate.
|
|
1363
|
+
base_event_name = "Event"
|
|
1364
|
+
base_event_cls: type[CanMutateAggregate[TAggregateID]] | None = None
|
|
1365
|
+
msg = f"Base event class 'Event' not defined on {cls} or ancestors"
|
|
1366
|
+
base_event_class_not_defined_error = TypeError(msg)
|
|
1367
|
+
|
|
1368
|
+
try:
|
|
1369
|
+
base_event_cls = cls.__dict__[base_event_name]
|
|
1370
|
+
except KeyError:
|
|
1371
|
+
try:
|
|
1372
|
+
super_base_event_cls = getattr(cls, base_event_name)
|
|
1373
|
+
except AttributeError:
|
|
1374
|
+
pass
|
|
1375
|
+
else:
|
|
1376
|
+
base_event_cls = cls._define_event_class(
|
|
1377
|
+
name=base_event_name,
|
|
1378
|
+
bases=(super_base_event_cls,),
|
|
1379
|
+
apply_method=None,
|
|
1380
|
+
)
|
|
1381
|
+
setattr(cls, base_event_name, base_event_cls)
|
|
1382
|
+
|
|
1383
|
+
# Remember which events have been redefined, to preserve apparent hierarchy,
|
|
1384
|
+
# in a mapping from the original class to the redefined class.
|
|
1385
|
+
redefined_event_classes: dict[
|
|
1386
|
+
type[CanMutateAggregate[TAggregateID]],
|
|
1387
|
+
type[CanMutateAggregate[TAggregateID]],
|
|
1388
|
+
] = {}
|
|
1389
|
+
|
|
1390
|
+
# Remember any "created" event classes that are discovered.
|
|
1391
|
+
created_event_classes: dict[str, type[CanInitAggregate[TAggregateID]]] = {}
|
|
1392
|
+
|
|
1393
|
+
# TODO: Review decorator processing below to see if subclassing can be improved.
|
|
1394
|
+
# - basically, look at the decorators first, build a plan for defining events
|
|
1395
|
+
|
|
1396
|
+
# Ensure events defined on this class are subclasses of the base event class.
|
|
1397
|
+
for name, value in tuple(cls.__dict__.items()):
|
|
1398
|
+
# Don't subclass the base event class again.
|
|
1399
|
+
if name == base_event_name:
|
|
1400
|
+
continue
|
|
1401
|
+
|
|
1402
|
+
# Don't subclass lowercase named attributes.
|
|
1403
|
+
if name.lower() == name:
|
|
1404
|
+
continue
|
|
1405
|
+
|
|
1406
|
+
# Don't subclass if not "CanMutateAggregate".
|
|
1407
|
+
if not isinstance(value, type) or not issubclass(value, CanMutateAggregate):
|
|
1408
|
+
continue
|
|
1409
|
+
|
|
1410
|
+
# # Don't subclass generic classes (we don't have a type argument).
|
|
1411
|
+
# # TODO: Maybe also prohibit triggering such things?
|
|
1412
|
+
# if value.__dict__.get("__parameters__", ()):
|
|
1413
|
+
# continue
|
|
1414
|
+
|
|
1415
|
+
# Check we have a base event class.
|
|
1416
|
+
if base_event_cls is None:
|
|
1417
|
+
raise base_event_class_not_defined_error
|
|
1418
|
+
|
|
1419
|
+
# Redefine events that aren't already subclass of the base event class.
|
|
1420
|
+
if not issubclass(value, base_event_cls):
|
|
1421
|
+
# Identify base classes that were redefined, to preserve hierarchy.
|
|
1422
|
+
redefined_bases = []
|
|
1423
|
+
for base in value.__bases__:
|
|
1424
|
+
if base in redefined_event_classes:
|
|
1425
|
+
redefined_bases.append(redefined_event_classes[base])
|
|
1426
|
+
elif "__pydantic_generic_metadata__" in base.__dict__:
|
|
1427
|
+
pydantic_metadata = base.__dict__[
|
|
1428
|
+
"__pydantic_generic_metadata__"
|
|
1429
|
+
]
|
|
1430
|
+
for i, key in enumerate(pydantic_metadata):
|
|
1431
|
+
if key == "origin":
|
|
1432
|
+
origin = base.__bases__[i]
|
|
1433
|
+
if origin in redefined_event_classes:
|
|
1434
|
+
redefined_bases.append(
|
|
1435
|
+
redefined_event_classes[origin]
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
# Decide base classes of redefined event class: it must be
|
|
1439
|
+
# a subclass of the original class, all redefined classes that
|
|
1440
|
+
# were in its bases, and the aggregate's base event class.
|
|
1441
|
+
event_class_bases = (
|
|
1442
|
+
value,
|
|
1443
|
+
*redefined_bases,
|
|
1444
|
+
base_event_cls,
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
# Define event class.
|
|
1448
|
+
event_class = cls._define_event_class(name, event_class_bases, None)
|
|
1449
|
+
setattr(cls, name, event_class)
|
|
1450
|
+
|
|
1451
|
+
# Remember which events have been redefined.
|
|
1452
|
+
redefined_event_classes[value] = event_class
|
|
1453
|
+
else:
|
|
1454
|
+
event_class = value
|
|
1455
|
+
|
|
1456
|
+
# Remember all "created" event classes defined on this class.
|
|
1457
|
+
if issubclass(event_class, CanInitAggregate):
|
|
1458
|
+
created_event_classes[name] = event_class
|
|
1459
|
+
|
|
1460
|
+
# Identify or define the aggregate's "created" event class.
|
|
1461
|
+
created_event_class: type[CanInitAggregate[TAggregateID]] | None = None
|
|
1462
|
+
created_event_topic: str | None = None
|
|
1463
|
+
|
|
1464
|
+
# Analyse __init__ method decorator.
|
|
1465
|
+
if init_decorator:
|
|
1466
|
+
|
|
1467
|
+
# Does the decorator specify an event class?
|
|
1468
|
+
if init_decorator.given_event_cls:
|
|
1469
|
+
|
|
1470
|
+
# Disallow conflicts between 'created_event_name' and given class.
|
|
1471
|
+
if (
|
|
1472
|
+
created_event_name
|
|
1473
|
+
and created_event_name != init_decorator.given_event_cls.__name__
|
|
1474
|
+
):
|
|
1475
|
+
msg = (
|
|
1476
|
+
"Given 'created_event_name' conflicts "
|
|
1477
|
+
"with decorator on __init__"
|
|
1478
|
+
)
|
|
1479
|
+
raise TypeError(msg)
|
|
1480
|
+
|
|
1481
|
+
# Check given event class can init aggregate.
|
|
1482
|
+
if not issubclass(init_decorator.given_event_cls, CanInitAggregate):
|
|
1483
|
+
msg = (
|
|
1484
|
+
f"class '{init_decorator.given_event_cls.__name__}' "
|
|
1485
|
+
f'not a "created" event class'
|
|
1486
|
+
)
|
|
1487
|
+
raise TypeError(msg)
|
|
1488
|
+
|
|
1489
|
+
# Have we already subclassed the given event class?
|
|
1490
|
+
for sub_class in created_event_classes.values():
|
|
1491
|
+
if issubclass(sub_class, init_decorator.given_event_cls):
|
|
1492
|
+
created_event_class = sub_class
|
|
1493
|
+
break
|
|
1494
|
+
else:
|
|
1495
|
+
created_event_class = init_decorator.given_event_cls
|
|
1496
|
+
|
|
1497
|
+
# Does the decorator specify an event name?
|
|
1498
|
+
elif init_decorator.event_cls_name:
|
|
1499
|
+
created_event_topic = init_decorator.event_topic
|
|
1500
|
+
# Disallow conflicts between 'created_event_name' and given name.
|
|
1501
|
+
if (
|
|
1502
|
+
created_event_name
|
|
1503
|
+
and created_event_name != init_decorator.event_cls_name
|
|
1504
|
+
):
|
|
1505
|
+
msg = (
|
|
1506
|
+
"Given 'created_event_name' conflicts "
|
|
1507
|
+
"with decorator on __init__"
|
|
1508
|
+
)
|
|
1509
|
+
raise TypeError(msg)
|
|
1510
|
+
|
|
1511
|
+
created_event_name = init_decorator.event_cls_name
|
|
1512
|
+
|
|
1513
|
+
# Disallow using decorator on __init__ without event name or class.
|
|
1514
|
+
else:
|
|
1515
|
+
msg = "@event decorator on __init__ has neither event name nor class"
|
|
1516
|
+
raise TypeError(msg)
|
|
1517
|
+
|
|
1518
|
+
# Do we need to define a created event class?
|
|
1519
|
+
if not created_event_class:
|
|
1520
|
+
# If we have a "created" event class that matches the name, then use it.
|
|
1521
|
+
if created_event_name in created_event_classes:
|
|
1522
|
+
created_event_class = created_event_classes[created_event_name]
|
|
1523
|
+
# Otherwise, if we have no name and only one class defined, then use it.
|
|
1524
|
+
elif not created_event_name and len(created_event_classes) == 1:
|
|
1525
|
+
created_event_class = next(iter(created_event_classes.values()))
|
|
1526
|
+
|
|
1527
|
+
# Otherwise, if there are no "created" event classes, or a name
|
|
1528
|
+
# is specified that hasn't matched, then try to define one.
|
|
1529
|
+
elif len(created_event_classes) == 0 or created_event_name:
|
|
1530
|
+
# Decide the base "created" event class.
|
|
1531
|
+
|
|
1532
|
+
base_created_event_cls: type[CanInitAggregate[TAggregateID]] | None = (
|
|
1533
|
+
None
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
if created_event_name:
|
|
1537
|
+
# Look for a base class with the same name.
|
|
1538
|
+
with contextlib.suppress(AttributeError):
|
|
1539
|
+
base_created_event_cls = cast(
|
|
1540
|
+
type[CanInitAggregate[TAggregateID]],
|
|
1541
|
+
getattr(cls, created_event_name),
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
if base_created_event_cls is None:
|
|
1545
|
+
# Look for base class with one nominated "created" event.
|
|
1546
|
+
for base_cls in cls.__mro__:
|
|
1547
|
+
if (
|
|
1548
|
+
base_cls in _created_event_classes
|
|
1549
|
+
and len(_created_event_classes[base_cls]) == 1
|
|
1550
|
+
):
|
|
1551
|
+
base_created_event_cls = _created_event_classes[base_cls][0]
|
|
1552
|
+
break
|
|
1553
|
+
|
|
1554
|
+
if base_created_event_cls:
|
|
1555
|
+
if not created_event_name:
|
|
1556
|
+
created_event_name = base_created_event_cls.__name__
|
|
1557
|
+
|
|
1558
|
+
# Disallow init method from having variable params, because
|
|
1559
|
+
# we are using it to define a "created" event class.
|
|
1560
|
+
if init_method:
|
|
1561
|
+
_raise_type_error_if_func_has_variable_params(init_method)
|
|
1562
|
+
|
|
1563
|
+
# Sanity check: we have a base event class.
|
|
1564
|
+
assert base_event_cls is not None
|
|
1565
|
+
# Sanity check: the base created event class is a class.
|
|
1566
|
+
assert isinstance(
|
|
1567
|
+
base_created_event_cls, type
|
|
1568
|
+
), base_created_event_cls
|
|
1569
|
+
# Sanity check: base created event not subclass of base event class.
|
|
1570
|
+
assert not issubclass(
|
|
1571
|
+
base_created_event_cls, base_event_cls
|
|
1572
|
+
), base_created_event_cls
|
|
1573
|
+
|
|
1574
|
+
# Define "created" event class.
|
|
1575
|
+
assert created_event_name
|
|
1576
|
+
assert issubclass(base_created_event_cls, CanInitAggregate)
|
|
1577
|
+
created_event_class_bases = (base_created_event_cls, base_event_cls)
|
|
1578
|
+
created_event_class = cast(
|
|
1579
|
+
type[CanInitAggregate[TAggregateID]],
|
|
1580
|
+
cls._define_event_class(
|
|
1581
|
+
created_event_name,
|
|
1582
|
+
created_event_class_bases,
|
|
1583
|
+
init_method,
|
|
1584
|
+
event_topic=created_event_topic,
|
|
1585
|
+
),
|
|
1586
|
+
)
|
|
1587
|
+
# Set the event class as an attribute of the aggregate class.
|
|
1588
|
+
setattr(cls, created_event_name, created_event_class)
|
|
1589
|
+
|
|
1590
|
+
elif created_event_name:
|
|
1591
|
+
msg = (
|
|
1592
|
+
'Can\'t defined "created" event class '
|
|
1593
|
+
f"for name '{created_event_name}'"
|
|
1594
|
+
)
|
|
1595
|
+
raise TypeError(msg)
|
|
1596
|
+
|
|
1597
|
+
if created_event_class:
|
|
1598
|
+
_created_event_classes[cls] = [created_event_class]
|
|
1599
|
+
else:
|
|
1600
|
+
# Prepare to disallow any ambiguity of choice between created event classes.
|
|
1601
|
+
_created_event_classes[cls] = list(created_event_classes.values())
|
|
1602
|
+
|
|
1603
|
+
# Find and analyse any @event decorators.
|
|
1604
|
+
for attr_name, attr_value in tuple(cls.__dict__.items()):
|
|
1605
|
+
event_decorator: CommandMethodDecorator | None = None
|
|
1606
|
+
|
|
1607
|
+
# Ignore a decorator on the __init__ method.
|
|
1608
|
+
if isinstance(attr_value, CommandMethodDecorator) and (
|
|
1609
|
+
attr_value.decorated_func.__name__ == "__init__"
|
|
1610
|
+
):
|
|
1611
|
+
continue
|
|
1612
|
+
|
|
1613
|
+
# Handle @property.setter decorator on top of @event decorator.
|
|
1614
|
+
if isinstance(attr_value, property) and isinstance(
|
|
1615
|
+
attr_value.fset, CommandMethodDecorator
|
|
1616
|
+
):
|
|
1617
|
+
event_decorator = attr_value.fset
|
|
1618
|
+
# Inspect the setter method.
|
|
1619
|
+
method_signature = inspect.signature(event_decorator.decorated_func)
|
|
1620
|
+
assert len(method_signature.parameters) == 2
|
|
1621
|
+
event_decorator.is_property_setter = True
|
|
1622
|
+
event_decorator.property_setter_arg_name = list(
|
|
1623
|
+
method_signature.parameters
|
|
1624
|
+
)[1]
|
|
1625
|
+
if event_decorator.decorated_func.__name__ != attr_name:
|
|
1626
|
+
attr = cls.__dict__[event_decorator.decorated_func.__name__]
|
|
1627
|
+
if isinstance(attr, CommandMethodDecorator):
|
|
1628
|
+
# This is the "x = property(getx, setx) form" where setx
|
|
1629
|
+
# is a decorated method.
|
|
1630
|
+
continue
|
|
1631
|
+
# Otherwise, it's "x = property(getx, event(setx))".
|
|
1632
|
+
elif event_decorator.is_name_inferred_from_method:
|
|
1633
|
+
# This is the "@property.setter \ @event" form. We don't want
|
|
1634
|
+
# event class name inferred from property (not past participle).
|
|
1635
|
+
method_name = event_decorator.decorated_func.__name__
|
|
1636
|
+
msg = (
|
|
1637
|
+
f"@event decorator under @{method_name}.setter "
|
|
1638
|
+
"requires event name or class"
|
|
1639
|
+
)
|
|
1640
|
+
raise TypeError(msg)
|
|
1641
|
+
|
|
1642
|
+
elif isinstance(attr_value, CommandMethodDecorator):
|
|
1643
|
+
event_decorator = attr_value
|
|
1644
|
+
|
|
1645
|
+
if event_decorator is not None:
|
|
1646
|
+
if event_decorator.given_event_cls:
|
|
1647
|
+
# Check this is not a "created" event class.
|
|
1648
|
+
if issubclass(event_decorator.given_event_cls, CanInitAggregate):
|
|
1649
|
+
msg = (
|
|
1650
|
+
f"{event_decorator.given_event_cls} "
|
|
1651
|
+
f"is subclass of {CanInitAggregate.__name__}"
|
|
1652
|
+
)
|
|
1653
|
+
raise TypeError(msg)
|
|
1654
|
+
|
|
1655
|
+
# Define event class as subclass of given class.
|
|
1656
|
+
given_subclass = cast(
|
|
1657
|
+
type[CanMutateAggregate[TAggregateID]],
|
|
1658
|
+
getattr(cls, event_decorator.given_event_cls.__name__),
|
|
1659
|
+
)
|
|
1660
|
+
# TODO: Check if this subclassing means we can avoid some of
|
|
1661
|
+
# the subclassing of events above? Maybe do this first?
|
|
1662
|
+
event_cls = cls._define_event_class(
|
|
1663
|
+
event_decorator.given_event_cls.__name__,
|
|
1664
|
+
(DecoratedFuncCaller, given_subclass),
|
|
1665
|
+
None,
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
else:
|
|
1669
|
+
# Check event class isn't already defined.
|
|
1670
|
+
assert event_decorator.event_cls_name
|
|
1671
|
+
if event_decorator.event_cls_name in cls.__dict__:
|
|
1672
|
+
msg = (
|
|
1673
|
+
f"{event_decorator.event_cls_name} "
|
|
1674
|
+
f"event already defined on {cls.__name__}"
|
|
1675
|
+
)
|
|
1676
|
+
raise TypeError(msg)
|
|
1677
|
+
|
|
1678
|
+
# Check we have a base event class.
|
|
1679
|
+
if base_event_cls is None:
|
|
1680
|
+
raise base_event_class_not_defined_error
|
|
1681
|
+
|
|
1682
|
+
# Define event class from signature of original method.
|
|
1683
|
+
event_cls = cls._define_event_class(
|
|
1684
|
+
event_decorator.event_cls_name,
|
|
1685
|
+
(DecoratedFuncCaller, base_event_cls),
|
|
1686
|
+
event_decorator.decorated_func,
|
|
1687
|
+
event_topic=event_decorator.event_topic,
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
# Cache the decorated method for the event class to use.
|
|
1691
|
+
decorated_funcs[event_cls] = event_decorator.decorated_func
|
|
1692
|
+
|
|
1693
|
+
# Set the event class as an attribute of the aggregate class.
|
|
1694
|
+
setattr(cls, event_cls.__name__, event_cls)
|
|
1695
|
+
|
|
1696
|
+
# Remember which event class to trigger.
|
|
1697
|
+
decorated_func_callers[event_decorator] = cast(
|
|
1698
|
+
type[DecoratedFuncCaller], event_cls
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
# Check any create_id() method defined on this class is static or class method.
|
|
1702
|
+
if "create_id" in cls.__dict__ and not isinstance(
|
|
1703
|
+
cls.__dict__["create_id"], (staticmethod, classmethod)
|
|
1704
|
+
):
|
|
1705
|
+
msg = (
|
|
1706
|
+
f"{cls.create_id} is not a static or class method: "
|
|
1707
|
+
f"{type(cls.create_id)}"
|
|
1708
|
+
)
|
|
1709
|
+
raise TypeError(msg)
|
|
1710
|
+
|
|
1711
|
+
# Get the parameters of the create_id method that will be used by this class.
|
|
1712
|
+
for name, param in inspect.signature(cls.create_id).parameters.items():
|
|
1713
|
+
if param.kind in [param.KEYWORD_ONLY, param.POSITIONAL_OR_KEYWORD]:
|
|
1714
|
+
_create_id_param_names[cls].append(name)
|
|
1715
|
+
|
|
1716
|
+
# Define event classes for all events on all bases if not defined on this class.
|
|
1717
|
+
for aggregate_base_class in cls.__bases__:
|
|
1718
|
+
for name, value in aggregate_base_class.__dict__.items():
|
|
1719
|
+
if (
|
|
1720
|
+
isinstance(value, type)
|
|
1721
|
+
and issubclass(value, CanMutateAggregate)
|
|
1722
|
+
and name not in cls.__dict__
|
|
1723
|
+
and name.lower() != name
|
|
1724
|
+
):
|
|
1725
|
+
# Sanity check: we have a base event class.
|
|
1726
|
+
assert base_event_cls is not None
|
|
1727
|
+
event_class = cls._define_event_class(
|
|
1728
|
+
name, (base_event_cls, value), None
|
|
1729
|
+
)
|
|
1730
|
+
setattr(cls, name, event_class)
|
|
1731
|
+
|
|
1732
|
+
if getattr(cls, "TOPIC", None):
|
|
1733
|
+
|
|
1734
|
+
explicit_topic = cls.__dict__.get("TOPIC", None)
|
|
1735
|
+
|
|
1736
|
+
if not explicit_topic:
|
|
1737
|
+
msg = f"Explicit topic not defined on {cls}"
|
|
1738
|
+
raise ProgrammingError(msg)
|
|
1739
|
+
|
|
1740
|
+
try:
|
|
1741
|
+
register_topic(explicit_topic, cls)
|
|
1742
|
+
except TopicError:
|
|
1743
|
+
msg = (
|
|
1744
|
+
f"Explicit topic '{explicit_topic}' of {cls} "
|
|
1745
|
+
f"already registered for {resolve_topic(explicit_topic)}"
|
|
1746
|
+
)
|
|
1747
|
+
raise ProgrammingError(msg) from None
|
|
1748
|
+
|
|
1749
|
+
for name, obj in cls.__dict__.items():
|
|
1750
|
+
if (
|
|
1751
|
+
isinstance(obj, type)
|
|
1752
|
+
and issubclass(obj, CanMutateAggregate)
|
|
1753
|
+
and name != "Event"
|
|
1754
|
+
):
|
|
1755
|
+
explicit_topic = getattr(obj, "TOPIC", None)
|
|
1756
|
+
if not explicit_topic:
|
|
1757
|
+
msg = f"Explicit topic not defined on {obj}"
|
|
1758
|
+
raise ProgrammingError(msg)
|
|
1759
|
+
try:
|
|
1760
|
+
register_topic(explicit_topic, obj)
|
|
1761
|
+
except TopicError:
|
|
1762
|
+
msg = (
|
|
1763
|
+
f"Explicit topic '{explicit_topic}' of {obj} "
|
|
1764
|
+
f"already registered for {resolve_topic(explicit_topic)}"
|
|
1765
|
+
)
|
|
1766
|
+
raise ProgrammingError(msg) from None
|
|
1767
|
+
|
|
1768
|
+
def __hash__(self) -> int:
|
|
1769
|
+
raise NotImplementedError # pragma: no cover
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _check_explicit_topic_is_registered(event_class: type[object]) -> None:
|
|
1773
|
+
explicit_topic = getattr(event_class, "TOPIC", None)
|
|
1774
|
+
if not explicit_topic:
|
|
1775
|
+
msg = f"Explicit topic not defined on {event_class}"
|
|
1776
|
+
raise ProgrammingError(msg)
|
|
1777
|
+
try:
|
|
1778
|
+
resolved_obj = resolve_topic(explicit_topic)
|
|
1779
|
+
except TopicError:
|
|
1780
|
+
msg = f"Explicit topic '{explicit_topic}' on {event_class} is not registered"
|
|
1781
|
+
raise ProgrammingError(msg) from None
|
|
1782
|
+
if resolved_obj is not event_class:
|
|
1783
|
+
msg = (
|
|
1784
|
+
f"Explicit topic '{explicit_topic}' on {event_class} "
|
|
1785
|
+
f"already registered for {resolved_obj}"
|
|
1786
|
+
)
|
|
1787
|
+
raise ProgrammingError(msg) from None
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
class OriginatorIDError(EventSourcingError):
|
|
1791
|
+
"""Raised when a domain event can't be applied to
|
|
1792
|
+
an aggregate due to an ID mismatch indicating
|
|
1793
|
+
the domain event is not in the aggregate's
|
|
1794
|
+
sequence of events.
|
|
1795
|
+
"""
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
class OriginatorVersionError(EventSourcingError):
|
|
1799
|
+
"""Raised when a domain event can't be applied to
|
|
1800
|
+
an aggregate due to version mismatch indicating
|
|
1801
|
+
the domain event is not the next in the aggregate's
|
|
1802
|
+
sequence of events.
|
|
1803
|
+
"""
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
class SnapshotProtocol(DomainEventProtocol[TAggregateID_co], Protocol):
|
|
1807
|
+
@property
|
|
1808
|
+
def state(self) -> Any:
|
|
1809
|
+
"""Snapshots have a read-only 'state'."""
|
|
1810
|
+
raise NotImplementedError # pragma: no cover
|
|
1811
|
+
|
|
1812
|
+
# TODO: Improve on this 'Any'.
|
|
1813
|
+
@classmethod
|
|
1814
|
+
def take(cls: Any, aggregate: Any) -> Any:
|
|
1815
|
+
"""Snapshots have a 'take()' class method."""
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
class CanSnapshotAggregate(HasOriginatorIDVersion[TAggregateID], CanCreateTimestamp):
|
|
1819
|
+
topic: str
|
|
1820
|
+
state: Any
|
|
1821
|
+
|
|
1822
|
+
def __init_subclass__(cls) -> None:
|
|
1823
|
+
cls.find_originator_id_type(CanSnapshotAggregate)
|
|
1824
|
+
super().__init_subclass__()
|
|
1825
|
+
|
|
1826
|
+
# def __init__(
|
|
1827
|
+
# self,
|
|
1828
|
+
# originator_id: UUID,
|
|
1829
|
+
# originator_version: int,
|
|
1830
|
+
# timestamp: datetime,
|
|
1831
|
+
# topic: str,
|
|
1832
|
+
# state: Any,
|
|
1833
|
+
# ) -> None:
|
|
1834
|
+
# raise NotImplementedError # pragma: no cover
|
|
1835
|
+
|
|
1836
|
+
@classmethod
|
|
1837
|
+
def take(
|
|
1838
|
+
cls,
|
|
1839
|
+
aggregate: MutableOrImmutableAggregate[TAggregateID],
|
|
1840
|
+
) -> Self:
|
|
1841
|
+
"""Creates a snapshot of the given :class:`Aggregate` object."""
|
|
1842
|
+
aggregate_state = dict(aggregate.__dict__)
|
|
1843
|
+
class_version = getattr(type(aggregate), "class_version", 1)
|
|
1844
|
+
if class_version > 1:
|
|
1845
|
+
aggregate_state["class_version"] = class_version
|
|
1846
|
+
if isinstance(aggregate, Aggregate):
|
|
1847
|
+
aggregate_state.pop("_id")
|
|
1848
|
+
aggregate_state.pop("_version")
|
|
1849
|
+
aggregate_state.pop("_pending_events")
|
|
1850
|
+
return cls(
|
|
1851
|
+
originator_id=aggregate.id, # type: ignore[call-arg]
|
|
1852
|
+
originator_version=aggregate.version, # pyright: ignore[reportCallIssue]
|
|
1853
|
+
timestamp=cls.create_timestamp(), # pyright: ignore[reportCallIssue]
|
|
1854
|
+
topic=get_topic(type(aggregate)), # pyright: ignore[reportCallIssue]
|
|
1855
|
+
state=aggregate_state, # pyright: ignore[reportCallIssue]
|
|
1856
|
+
)
|
|
1857
|
+
|
|
1858
|
+
def mutate(self, _: None) -> BaseAggregate[TAggregateID]:
|
|
1859
|
+
"""Reconstructs the snapshotted :class:`Aggregate` object."""
|
|
1860
|
+
cls = cast(type[BaseAggregate[TAggregateID]], resolve_topic(self.topic))
|
|
1861
|
+
aggregate_state = dict(self.state)
|
|
1862
|
+
from_version = aggregate_state.pop("class_version", 1)
|
|
1863
|
+
class_version = getattr(cls, "class_version", 1)
|
|
1864
|
+
while from_version < class_version:
|
|
1865
|
+
upcast_name = f"upcast_v{from_version}_v{from_version + 1}"
|
|
1866
|
+
upcast = getattr(cls, upcast_name)
|
|
1867
|
+
upcast(aggregate_state)
|
|
1868
|
+
from_version += 1
|
|
1869
|
+
|
|
1870
|
+
aggregate_state["_id"] = self.originator_id
|
|
1871
|
+
aggregate_state["_version"] = self.originator_version
|
|
1872
|
+
aggregate_state["_pending_events"] = []
|
|
1873
|
+
aggregate = object.__new__(cls)
|
|
1874
|
+
aggregate.__dict__.update(aggregate_state)
|
|
1875
|
+
return aggregate
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
@dataclass(frozen=True)
|
|
1879
|
+
class Snapshot(CanSnapshotAggregate[UUID], DomainEvent):
|
|
1880
|
+
"""Snapshots represent the state of an aggregate at a particular
|
|
1881
|
+
version.
|
|
1882
|
+
|
|
1883
|
+
Constructor arguments:
|
|
1884
|
+
|
|
1885
|
+
:param UUID originator_id: ID of originating aggregate.
|
|
1886
|
+
:param int originator_version: version of originating aggregate.
|
|
1887
|
+
:param datetime timestamp: date-time of the event
|
|
1888
|
+
:param str topic: string that includes a class and its module
|
|
1889
|
+
:param dict state: state of originating aggregate.
|
|
1890
|
+
"""
|
|
1891
|
+
|
|
1892
|
+
topic: str
|
|
1893
|
+
state: dict[str, Any]
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
class Aggregate(BaseAggregate[UUID]):
|
|
1897
|
+
@staticmethod
|
|
1898
|
+
def create_id(*_: Any, **__: Any) -> UUID:
|
|
1899
|
+
"""Returns a new aggregate ID."""
|
|
1900
|
+
return uuid4()
|
|
1901
|
+
|
|
1902
|
+
class Event(AggregateEvent):
|
|
1903
|
+
pass
|
|
1904
|
+
|
|
1905
|
+
class Created(Event, AggregateCreated):
|
|
1906
|
+
pass
|
|
1907
|
+
|
|
1908
|
+
Snapshot = Snapshot
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
@overload
|
|
1912
|
+
def aggregate(*, created_event_name: str) -> Callable[[Any], type[Aggregate]]:
|
|
1913
|
+
pass # pragma: no cover
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
@overload
|
|
1917
|
+
def aggregate(cls: Any) -> type[Aggregate]:
|
|
1918
|
+
pass # pragma: no cover
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def aggregate(
|
|
1922
|
+
cls: Any | None = None,
|
|
1923
|
+
*,
|
|
1924
|
+
created_event_name: str = "",
|
|
1925
|
+
) -> type[Aggregate] | Callable[[Any], type[Aggregate]]:
|
|
1926
|
+
"""Converts the class that was passed in to inherit from Aggregate.
|
|
1927
|
+
|
|
1928
|
+
.. code-block:: python
|
|
1929
|
+
|
|
1930
|
+
@aggregate
|
|
1931
|
+
class MyAggregate:
|
|
1932
|
+
pass
|
|
1933
|
+
|
|
1934
|
+
...is equivalent to...
|
|
1935
|
+
|
|
1936
|
+
.. code-block:: python
|
|
1937
|
+
|
|
1938
|
+
class MyAggregate(Aggregate):
|
|
1939
|
+
pass
|
|
1940
|
+
"""
|
|
1941
|
+
|
|
1942
|
+
def decorator(cls_: Any) -> type[Aggregate]:
|
|
1943
|
+
if issubclass(cls_, Aggregate):
|
|
1944
|
+
msg = f"{cls_.__qualname__} is already an Aggregate"
|
|
1945
|
+
raise TypeError(msg)
|
|
1946
|
+
bases = cls_.__bases__
|
|
1947
|
+
if bases == (object,):
|
|
1948
|
+
bases = (Aggregate,)
|
|
1949
|
+
else:
|
|
1950
|
+
bases += (Aggregate,)
|
|
1951
|
+
cls_dict = {}
|
|
1952
|
+
cls_dict.update(cls_.__dict__)
|
|
1953
|
+
cls_ = MetaAggregate(
|
|
1954
|
+
cls_.__qualname__,
|
|
1955
|
+
bases,
|
|
1956
|
+
cls_dict,
|
|
1957
|
+
created_event_name=created_event_name,
|
|
1958
|
+
)
|
|
1959
|
+
assert issubclass(cls_, Aggregate)
|
|
1960
|
+
return cls_
|
|
1961
|
+
|
|
1962
|
+
if cls:
|
|
1963
|
+
return decorator(cls)
|
|
1964
|
+
return decorator
|