eventsourcing 9.4.5__tar.gz → 9.5.0a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of eventsourcing might be problematic. Click here for more details.

Files changed (34) hide show
  1. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/PKG-INFO +2 -2
  2. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/application.py +16 -3
  3. eventsourcing-9.5.0a0/eventsourcing/dcb/api.py +65 -0
  4. eventsourcing-9.5.0a0/eventsourcing/dcb/application.py +116 -0
  5. eventsourcing-9.5.0a0/eventsourcing/dcb/domain.py +381 -0
  6. eventsourcing-9.5.0a0/eventsourcing/dcb/persistence.py +146 -0
  7. eventsourcing-9.5.0a0/eventsourcing/dcb/popo.py +95 -0
  8. eventsourcing-9.5.0a0/eventsourcing/dcb/postgres_tt.py +643 -0
  9. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/domain.py +147 -62
  10. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/persistence.py +60 -45
  11. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/popo.py +2 -2
  12. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/postgres.py +355 -132
  13. eventsourcing-9.5.0a0/eventsourcing/py.typed +0 -0
  14. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/sqlite.py +25 -3
  15. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/tests/application.py +5 -1
  16. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/tests/persistence.py +53 -80
  17. eventsourcing-9.5.0a0/eventsourcing/tests/postgres_utils.py +129 -0
  18. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/utils.py +7 -3
  19. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/pyproject.toml +3 -3
  20. eventsourcing-9.4.5/eventsourcing/tests/postgres_utils.py +0 -71
  21. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/AUTHORS +0 -0
  22. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/LICENSE +0 -0
  23. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/README.md +0 -0
  24. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/__init__.py +0 -0
  25. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/cipher.py +0 -0
  26. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/compressor.py +0 -0
  27. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/cryptography.py +0 -0
  28. /eventsourcing-9.4.5/eventsourcing/py.typed → /eventsourcing-9.5.0a0/eventsourcing/dcb/__init__.py +0 -0
  29. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/dispatch.py +0 -0
  30. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/interface.py +0 -0
  31. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/projection.py +0 -0
  32. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/system.py +0 -0
  33. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/tests/__init__.py +0 -0
  34. {eventsourcing-9.4.5 → eventsourcing-9.5.0a0}/eventsourcing/tests/domain.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: eventsourcing
3
- Version: 9.4.5
3
+ Version: 9.5.0a0
4
4
  Summary: Event sourcing in Python
5
5
  License: BSD-3-Clause
6
6
  Keywords: event sourcing,event store,domain driven design,domain-driven design,ddd,cqrs,cqs
7
7
  Author: John Bywater
8
8
  Author-email: john.bywater@appropriatesoftware.net
9
9
  Requires-Python: >=3.9.2
10
- Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: Education
13
13
  Classifier: Intended Audience :: Science/Research
@@ -54,6 +54,8 @@ from eventsourcing.utils import Environment, EnvType, strtobool
54
54
  if TYPE_CHECKING:
55
55
  from uuid import UUID
56
56
 
57
+ from typing_extensions import Self
58
+
57
59
  ProjectorFunction = Callable[
58
60
  [Optional[TMutableOrImmutableAggregate], Iterable[TDomainEvent]],
59
61
  Optional[TMutableOrImmutableAggregate],
@@ -279,6 +281,7 @@ class Repository(Generic[TAggregateID]):
279
281
  if self.fastforward:
280
282
  # Fast-forward cached aggregate.
281
283
  fastforward_lock = self._use_fastforward_lock(aggregate_id)
284
+ # TODO: Should this be 'fastforward or self.fastforward_skipping'?
282
285
  blocking = not (fastforward_skipping or self.fastforward_skipping)
283
286
  try:
284
287
  if fastforward_lock.acquire(blocking=blocking):
@@ -635,6 +638,7 @@ class Application(Generic[TAggregateID]):
635
638
  a :class:`~eventsourcing.application.Repository`, and
636
639
  a :class:`~eventsourcing.application.LocalNotificationLog`.
637
640
  """
641
+ self.closing = Event()
638
642
  self.env = self.construct_env(self.name, env) # type: ignore[misc]
639
643
  self.factory = self.construct_factory(self.env)
640
644
  self.mapper: Mapper[TAggregateID] = self.construct_mapper()
@@ -645,7 +649,6 @@ class Application(Generic[TAggregateID]):
645
649
  self.snapshots = self.construct_snapshot_store()
646
650
  self._repository: Repository[TAggregateID] = self.construct_repository()
647
651
  self._notification_log = self.construct_notification_log()
648
- self.closing = Event()
649
652
 
650
653
  @property
651
654
  def repository(self) -> Repository[TAggregateID]:
@@ -663,7 +666,7 @@ class Application(Generic[TAggregateID]):
663
666
  @property
664
667
  def log(self) -> LocalNotificationLog:
665
668
  warn(
666
- "'log' is deprecated, use 'notifications' instead",
669
+ "'log' is deprecated, use 'notification_log' instead",
667
670
  DeprecationWarning,
668
671
  stacklevel=2,
669
672
  )
@@ -840,7 +843,7 @@ class Application(Generic[TAggregateID]):
840
843
  "application class."
841
844
  )
842
845
  raise AssertionError(msg)
843
- aggregate: BaseAggregate[UUID | str] = self.repository.get(
846
+ aggregate: BaseAggregate[TAggregateID] = self.repository.get(
844
847
  aggregate_id, version=version, projector_func=projector_func
845
848
  )
846
849
  snapshot_class = getattr(type(aggregate), "Snapshot", type(self).snapshot_class)
@@ -875,6 +878,16 @@ class Application(Generic[TAggregateID]):
875
878
  self.closing.set()
876
879
  self.factory.close()
877
880
 
881
+ def __enter__(self) -> Self:
882
+ return self
883
+
884
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
885
+ self.close()
886
+
887
+ def __del__(self) -> None:
888
+ with contextlib.suppress(AttributeError):
889
+ self.close()
890
+
878
891
 
879
892
  TApplication = TypeVar("TApplication", bound=Application[Any])
880
893
 
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
10
+
11
+ @dataclass
12
+ class DCBQueryItem:
13
+ types: list[str] = field(default_factory=list)
14
+ tags: list[str] = field(default_factory=list)
15
+
16
+
17
+ @dataclass
18
+ class DCBQuery:
19
+ items: list[DCBQueryItem] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class DCBAppendCondition:
24
+ fail_if_events_match: DCBQuery = field(default_factory=DCBQuery)
25
+ after: int | None = None
26
+
27
+
28
+ @dataclass
29
+ class DCBEvent:
30
+ type: str
31
+ data: bytes
32
+ tags: list[str] = field(default_factory=list)
33
+
34
+
35
+ @dataclass
36
+ class DCBSequencedEvent:
37
+ event: DCBEvent
38
+ position: int
39
+
40
+
41
+ class DCBRecorder(ABC):
42
+
43
+ @abstractmethod
44
+ def read(
45
+ self,
46
+ query: DCBQuery | None = None,
47
+ *,
48
+ after: int | None = None,
49
+ limit: int | None = None,
50
+ ) -> tuple[Sequence[DCBSequencedEvent], int | None]:
51
+ """
52
+ Returns all events, unless 'after' is given then only those with position
53
+ greater than 'after', and unless any query items are given, then only those
54
+ that match at least one query item. An event matches a query item if its type
55
+ is in the item types or there are no item types, and if all the item tags are
56
+ in the event tags.
57
+ """
58
+
59
+ @abstractmethod
60
+ def append(
61
+ self, events: Sequence[DCBEvent], condition: DCBAppendCondition | None = None
62
+ ) -> int:
63
+ """
64
+ Appends given events to the event store, unless the condition fails.
65
+ """
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import TYPE_CHECKING, Any, ClassVar
5
+
6
+ from eventsourcing.dcb.domain import (
7
+ CanInitialiseEnduringObject,
8
+ CanMutateEnduringObject,
9
+ EnduringObject,
10
+ Perspective,
11
+ Selector,
12
+ )
13
+ from eventsourcing.dcb.persistence import (
14
+ DCBEventStore,
15
+ DCBInfrastructureFactory,
16
+ NotFoundError,
17
+ TGroup,
18
+ )
19
+ from eventsourcing.utils import Environment, EnvType
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Sequence
23
+
24
+ from typing_extensions import Self
25
+
26
+
27
+ class DCBApplication:
28
+ name = "DCBApplication"
29
+ env: ClassVar[dict[str, str]] = {"PERSISTENCE_MODULE": "eventsourcing.dcb.popo"}
30
+
31
+ def __init_subclass__(cls, **kwargs: Any) -> None:
32
+ if "name" not in cls.__dict__:
33
+ cls.name = cls.__name__
34
+
35
+ def __init__(self, env: EnvType | None = None):
36
+ self.env = self.construct_env(self.name, env) # type: ignore[misc]
37
+ self.factory = DCBInfrastructureFactory.construct(self.env)
38
+ self.recorder = self.factory.dcb_event_store()
39
+
40
+ def construct_env(self, name: str, env: EnvType | None = None) -> Environment:
41
+ """Constructs environment from which application will be configured."""
42
+ _env = dict(type(self).env)
43
+ _env.update(os.environ)
44
+ if env is not None:
45
+ _env.update(env)
46
+ return Environment(name, _env)
47
+
48
+ def __enter__(self) -> Self:
49
+ return self
50
+
51
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
52
+ self.factory.close()
53
+
54
+
55
+ class DCBRepository:
56
+ def __init__(self, eventstore: DCBEventStore):
57
+ self.eventstore = eventstore
58
+
59
+ def save(self, obj: Perspective) -> int:
60
+ new_events = obj.collect_events()
61
+ return self.eventstore.put(
62
+ *new_events, cb=obj.cb, after=obj.last_known_position
63
+ )
64
+
65
+ def get(
66
+ self,
67
+ enduring_object_id: str,
68
+ cb_types: Sequence[type[CanMutateEnduringObject]] = (),
69
+ ) -> EnduringObject:
70
+ cb = [Selector(tags=[enduring_object_id], types=cb_types)]
71
+ events, head = self.eventstore.get(*cb, with_last_position=True)
72
+ obj: EnduringObject | None = None
73
+ for event in events:
74
+ obj = event.mutate(obj)
75
+ if obj is None:
76
+ raise NotFoundError
77
+ obj.last_known_position = head
78
+ obj.cb_types = cb_types
79
+ return obj
80
+
81
+ def get_many(
82
+ self,
83
+ *enduring_object_ids: str,
84
+ cb_types: Sequence[type[CanMutateEnduringObject]] = (),
85
+ ) -> list[EnduringObject | None]:
86
+ cb = [
87
+ Selector(tags=[enduring_object_id], types=cb_types)
88
+ for enduring_object_id in enduring_object_ids
89
+ ]
90
+ events, head = self.eventstore.get(cb, with_last_position=True)
91
+ objs: dict[str, EnduringObject | None] = dict.fromkeys(enduring_object_ids)
92
+ for event in events:
93
+ for tag in event.tags:
94
+ obj = objs.get(tag)
95
+ if not isinstance(event, CanInitialiseEnduringObject) and not obj:
96
+ continue
97
+ obj = event.mutate(obj)
98
+ objs[tag] = obj
99
+ for obj in objs.values():
100
+ if obj is not None:
101
+ obj.last_known_position = head
102
+ obj.cb_types = cb_types
103
+ return list(objs.values())
104
+
105
+ def get_group(self, cls: type[TGroup], *enduring_object_ids: str) -> TGroup:
106
+ enduring_objects = self.get_many(*enduring_object_ids, cb_types=cls.cb_types)
107
+ perspective = cls(*enduring_objects)
108
+ last_known_positions = [
109
+ o.last_known_position
110
+ for o in enduring_objects
111
+ if o and o.last_known_position
112
+ ]
113
+ perspective.last_known_position = (
114
+ max(last_known_positions) if last_known_positions else None
115
+ )
116
+ return perspective
@@ -0,0 +1,381 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
6
+ from uuid import uuid4
7
+
8
+ from typing_extensions import Self
9
+
10
+ from eventsourcing.domain import (
11
+ AbstractDCBEvent,
12
+ AbstractDecoratedFuncCaller,
13
+ CallableType,
14
+ CommandMethodDecorator,
15
+ ProgrammingError,
16
+ decorated_func_callers,
17
+ decorated_funcs,
18
+ filter_kwargs_for_method_params,
19
+ underscore_method_decorators,
20
+ )
21
+ from eventsourcing.persistence import IntegrityError
22
+ from eventsourcing.utils import construct_topic, get_topic, resolve_topic
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Sequence
26
+
27
+ _enduring_object_init_classes: dict[type[Any], type[CanInitialiseEnduringObject]] = {}
28
+
29
+
30
+ class CanMutateEnduringObject(AbstractDCBEvent):
31
+ tags: list[str]
32
+
33
+ def _as_dict(self) -> dict[str, Any]:
34
+ raise NotImplementedError # pragma: no cover
35
+
36
+ def mutate(self, obj: EnduringObject | None) -> EnduringObject | None:
37
+ assert obj is not None
38
+ self.apply(obj)
39
+ return obj
40
+
41
+ def apply(self, obj: Any) -> None:
42
+ pass
43
+
44
+
45
+ class CanInitialiseEnduringObject(CanMutateEnduringObject):
46
+ originator_topic: str
47
+
48
+ def mutate(self, obj: EnduringObject | None) -> EnduringObject | None:
49
+ kwargs = self._as_dict()
50
+ originator_topic = resolve_topic(kwargs.pop("originator_topic"))
51
+ enduring_object_cls = cast(type[EnduringObject], originator_topic)
52
+ enduring_object_id = kwargs.pop(self.id_attr_name(enduring_object_cls))
53
+ kwargs.pop("tags")
54
+ try:
55
+ enduring_object = type.__call__(enduring_object_cls, **kwargs)
56
+ except TypeError as e:
57
+ msg = (
58
+ f"{type(self).__qualname__} cannot __init__ "
59
+ f"{enduring_object_cls.__qualname__} "
60
+ f"with kwargs {kwargs}: {e}"
61
+ )
62
+ raise TypeError(msg) from e
63
+ enduring_object.id = enduring_object_id
64
+ enduring_object.__post_init__()
65
+ return enduring_object
66
+
67
+ @classmethod
68
+ def id_attr_name(cls, enduring_object_class: type[EnduringObject]) -> str:
69
+ return f"{enduring_object_class.__name__.lower()}_id"
70
+
71
+
72
+ class DecoratedFuncCaller(CanMutateEnduringObject, AbstractDecoratedFuncCaller):
73
+ def apply(self, obj: EnduringObject) -> None:
74
+ """Applies event by calling method decorated by @event."""
75
+
76
+ event_class_topic = construct_topic(type(self))
77
+
78
+ # Identify the function that was decorated.
79
+ try:
80
+ # Either an "underscore" non-command method.
81
+ decorated_func_collection = cross_cutting_decorated_funcs[event_class_topic]
82
+ assert type(obj) in decorated_func_collection
83
+ decorated_func = decorated_func_collection[type(obj)]
84
+
85
+ except KeyError:
86
+ # Or a normal command method.
87
+ decorated_func = decorated_funcs[type(self)]
88
+
89
+ # Select event attributes mentioned in function signature.
90
+ self_dict = self._as_dict()
91
+ kwargs = filter_kwargs_for_method_params(self_dict, decorated_func)
92
+
93
+ # Call the original method with event attribute values.
94
+ decorated_method = decorated_func.__get__(obj, type(obj))
95
+ decorated_method(**kwargs)
96
+
97
+ # Call super method, just in case.
98
+ super().apply(obj)
99
+
100
+
101
+ T = TypeVar("T")
102
+
103
+
104
+ class MetaPerspective(type):
105
+ pass
106
+
107
+
108
+ class Perspective(metaclass=MetaPerspective):
109
+ last_known_position: int | None
110
+ cb_types: Sequence[type[CanMutateEnduringObject]] = ()
111
+ new_decisions: tuple[CanMutateEnduringObject, ...]
112
+
113
+ def __new__(cls, *_: Any, **__: Any) -> Self:
114
+ perspective = super().__new__(cls)
115
+ perspective.last_known_position = None
116
+ perspective.cb_types = cls.cb_types
117
+ perspective.new_decisions = ()
118
+ return perspective
119
+
120
+ def collect_events(self) -> Sequence[CanMutateEnduringObject]:
121
+ collected, self.new_decisions = self.new_decisions, ()
122
+ return collected
123
+
124
+ @property
125
+ def cb(self) -> list[Selector]:
126
+ raise NotImplementedError # pragma: no cover
127
+
128
+ def check_cb_types(self, decision_cls: type[CanMutateEnduringObject]) -> None:
129
+ if self.cb_types and decision_cls not in self.cb_types:
130
+ msg = (
131
+ f"Decision type {decision_cls.__qualname__} "
132
+ f"not in consistency boundary types: {self.cb_types}"
133
+ )
134
+ raise IntegrityError(msg)
135
+
136
+
137
+ cross_cutting_event_classes: dict[str, type[CanMutateEnduringObject]] = {}
138
+ cross_cutting_decorated_funcs: dict[str, dict[type, CallableType]] = {}
139
+
140
+
141
+ class MetaEnduringObject(MetaPerspective):
142
+ def __init__(
143
+ cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]
144
+ ) -> None:
145
+ super().__init__(name, bases, namespace)
146
+ # Find and remember the "initialised" class.
147
+ for item in cls.__dict__.values():
148
+ if isinstance(item, type) and issubclass(item, CanInitialiseEnduringObject):
149
+ _enduring_object_init_classes[cls] = item
150
+ break
151
+
152
+ # Process the event decorators.
153
+ for attr, value in namespace.items():
154
+ if isinstance(value, CommandMethodDecorator):
155
+ if attr == "_":
156
+ # Deal with cross-cutting events later.
157
+ continue
158
+
159
+ event_class = value.given_event_cls
160
+ # Just keep things simple by only supporting given classes (not names).
161
+ assert event_class is not None, "Event class not given"
162
+ assert issubclass(event_class, CanMutateEnduringObject)
163
+ # TODO: Maybe support event name strings, maybe not....
164
+ event_class_qual = event_class.__qualname__
165
+
166
+ # Keep things simple by only supporting nested classes.
167
+ assert event_class_qual.startswith(cls.__qualname__ + ".")
168
+ assert cls.__dict__[event_class.__name__] is event_class
169
+
170
+ # Subclass given class to make a "decorator class".
171
+ event_subclass_dict = {
172
+ "__module__": cls.__module__,
173
+ "__qualname__": event_class_qual,
174
+ }
175
+
176
+ subclass_name = event_class.__name__
177
+ event_subclass = cast(
178
+ type[DecoratedFuncCaller],
179
+ type(
180
+ subclass_name,
181
+ (DecoratedFuncCaller, event_class),
182
+ event_subclass_dict,
183
+ ),
184
+ )
185
+ # Update the enduring object class dict.
186
+ setattr(cls, event_class.__name__, event_subclass)
187
+ # Remember which event class to trigger when method is called.
188
+ decorated_func_callers[value] = event_subclass
189
+ # Remember which method body to execute when event is applied.
190
+ decorated_funcs[event_subclass] = value.decorated_func
191
+
192
+ # Deal with cross-cutting events.
193
+ enduring_object_class_topic = construct_topic(cls)
194
+ for topic, decorator in underscore_method_decorators:
195
+ if topic.startswith(enduring_object_class_topic):
196
+
197
+ event_class = decorator.given_event_cls
198
+ # Keep things simple by only supporting given classes (not names).
199
+ # TODO: Maybe support event name strings, maybe not....
200
+ assert event_class is not None, "Event class not given"
201
+ # Make sure event decorator has a CanMutateEnduringObject class.
202
+ assert issubclass(event_class, CanMutateEnduringObject)
203
+
204
+ # Assume this is a cross-cutting event, and we need to register
205
+ # multiple handler methods for the same class. Expect its mutate
206
+ # method will be called once for each enduring object tagged in
207
+ # its instances. The decorator event can then select which
208
+ # method body to call, according to the 'obj' argument of its
209
+ # apply() method. This means we do need to subclass the given
210
+ # event once only.
211
+ event_class_topic = construct_topic(event_class)
212
+ if event_class_topic not in cross_cutting_event_classes:
213
+ # Subclass the cross-cutting event class once only.
214
+ # - keep things simple by only supporting non-nested classes
215
+ event_class_qual = event_class.__qualname__
216
+ assert (
217
+ "." not in event_class_qual
218
+ ), "Nested cross-cutting classes aren't supported"
219
+ # Get the global namespace for the event class.
220
+ event_class_globalns = getattr(
221
+ sys.modules.get(event_class.__module__, None),
222
+ "__dict__",
223
+ {},
224
+ )
225
+ assert event_class_qual in event_class_globalns
226
+ event_subclass_dict = {
227
+ "__module__": cls.__module__,
228
+ "__qualname__": event_class_qual,
229
+ }
230
+ subclass_name = event_class.__name__
231
+ event_subclass = cast(
232
+ type[DecoratedFuncCaller],
233
+ type(
234
+ subclass_name,
235
+ (DecoratedFuncCaller, event_class),
236
+ event_subclass_dict,
237
+ ),
238
+ )
239
+ cross_cutting_event_classes[event_class_topic] = event_subclass
240
+ event_class_globalns[event_class_qual] = event_subclass
241
+
242
+ # Register decorated func for event class / enduring object class.
243
+ try:
244
+ decorated_func_collection = cross_cutting_decorated_funcs[
245
+ event_class_topic
246
+ ]
247
+ except KeyError:
248
+ decorated_func_collection = {}
249
+ cross_cutting_decorated_funcs[event_class_topic] = (
250
+ decorated_func_collection
251
+ )
252
+
253
+ decorated_func_collection[cls] = decorator.decorated_func
254
+
255
+ def __call__(cls: type[T], **kwargs: Any) -> T:
256
+ # TODO: For convenience, make this error out in the same way
257
+ # as it would if the arguments didn't match the __init__
258
+ # method and __init__was called directly, and verify the
259
+ # event's __init__ is valid when initialising the class,
260
+ # just like we do for event-sourced aggregates.
261
+
262
+ assert issubclass(cls, EnduringObject)
263
+ try:
264
+ init_enduring_object_class = _enduring_object_init_classes[cls]
265
+ except KeyError:
266
+ msg = (
267
+ f"Enduring object class {cls.__name__} has no "
268
+ f"CanInitialiseEnduringObject class. Please define a subclass of "
269
+ f"CanInitialiseEnduringObject as a nested class on {cls.__name__}."
270
+ )
271
+ raise ProgrammingError(msg) from None
272
+
273
+ return cast(
274
+ T,
275
+ cls._create(
276
+ decision_cls=init_enduring_object_class,
277
+ **kwargs,
278
+ ),
279
+ )
280
+
281
+
282
+ class EnduringObject(Perspective, metaclass=MetaEnduringObject):
283
+ id: str
284
+
285
+ @classmethod
286
+ def _create(
287
+ cls: type[Self], decision_cls: type[CanInitialiseEnduringObject], **kwargs: Any
288
+ ) -> Self:
289
+ enduring_object_id = cls._create_id()
290
+ id_attr_name = decision_cls.id_attr_name(cls)
291
+ assert id_attr_name not in kwargs
292
+ assert "originator_topic" not in kwargs
293
+ assert "tags" not in kwargs
294
+ initial_kwargs: dict[str, Any] = {
295
+ id_attr_name: enduring_object_id,
296
+ "originator_topic": get_topic(cls),
297
+ "tags": [enduring_object_id],
298
+ }
299
+ initial_kwargs.update(kwargs)
300
+ try:
301
+ initialised = decision_cls(**initial_kwargs)
302
+ except TypeError as e:
303
+ msg = (
304
+ f"Unable to construct {decision_cls.__qualname__} event "
305
+ f"with kwargs {initial_kwargs}: {e}"
306
+ )
307
+ raise TypeError(msg) from e
308
+ enduring_object = cast(Self, initialised.mutate(None))
309
+ assert enduring_object is not None
310
+ enduring_object.new_decisions += (initialised,)
311
+ return enduring_object
312
+
313
+ @classmethod
314
+ def _create_id(cls) -> str:
315
+ return f"{cls.__name__.lower()}-{uuid4()}"
316
+
317
+ def __post_init__(self) -> None:
318
+ pass
319
+
320
+ @property
321
+ def cb(self) -> list[Selector]:
322
+ return [Selector(tags=[self.id], types=self.cb_types)]
323
+
324
+ def trigger_event(
325
+ self,
326
+ decision_cls: type[CanMutateEnduringObject],
327
+ *,
328
+ tags: Sequence[str] = (),
329
+ **kwargs: Any,
330
+ ) -> None:
331
+ tags = [self.id, *tags]
332
+ kwargs["tags"] = tags
333
+ self.check_cb_types(decision_cls)
334
+ assert issubclass(decision_cls, DecoratedFuncCaller), decision_cls
335
+ decision = decision_cls(**kwargs)
336
+ decision.mutate(self)
337
+ self.new_decisions += (decision,)
338
+
339
+
340
+ class Group(Perspective):
341
+ @property
342
+ def cb(self) -> list[Selector]:
343
+ return [
344
+ Selector(types=tuple(self.cb_types) + tuple(cb.types), tags=cb.tags)
345
+ for cbs in [
346
+ o.cb for o in self.__dict__.values() if isinstance(o, EnduringObject)
347
+ ]
348
+ for cb in cbs
349
+ ]
350
+
351
+ def trigger_event(
352
+ self,
353
+ decision_cls: type[CanMutateEnduringObject],
354
+ *,
355
+ tags: Sequence[str] = (),
356
+ **kwargs: Any,
357
+ ) -> None:
358
+ self.check_cb_types(decision_cls)
359
+ objs = self.enduring_objects
360
+ tags = [o.id for o in objs] + list(tags)
361
+ kwargs["tags"] = tags
362
+ decision = decision_cls(**kwargs)
363
+ for o in objs:
364
+ decision.mutate(o)
365
+ self.new_decisions += (decision,)
366
+
367
+ @property
368
+ def enduring_objects(self) -> Sequence[EnduringObject]:
369
+ return [o for o in self.__dict__.values() if isinstance(o, EnduringObject)]
370
+
371
+ def collect_events(self) -> Sequence[CanMutateEnduringObject]:
372
+ group_events = list(super().collect_events())
373
+ for o in self.enduring_objects:
374
+ group_events.extend(o.collect_events())
375
+ return group_events
376
+
377
+
378
+ @dataclass
379
+ class Selector:
380
+ types: Sequence[type[CanMutateEnduringObject]] = ()
381
+ tags: Sequence[str] = ()