eventsourcing 9.4.6__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.
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/PKG-INFO +2 -2
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/application.py +15 -2
- eventsourcing-9.5.0a0/eventsourcing/dcb/api.py +65 -0
- eventsourcing-9.5.0a0/eventsourcing/dcb/application.py +116 -0
- eventsourcing-9.5.0a0/eventsourcing/dcb/domain.py +381 -0
- eventsourcing-9.5.0a0/eventsourcing/dcb/persistence.py +146 -0
- eventsourcing-9.5.0a0/eventsourcing/dcb/popo.py +95 -0
- eventsourcing-9.5.0a0/eventsourcing/dcb/postgres_tt.py +643 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/domain.py +89 -29
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/persistence.py +20 -25
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/popo.py +2 -2
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/postgres.py +355 -132
- eventsourcing-9.5.0a0/eventsourcing/py.typed +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/sqlite.py +25 -3
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/tests/application.py +5 -1
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/tests/persistence.py +53 -80
- eventsourcing-9.5.0a0/eventsourcing/tests/postgres_utils.py +129 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/utils.py +7 -3
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/pyproject.toml +3 -3
- eventsourcing-9.4.6/eventsourcing/tests/postgres_utils.py +0 -71
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/AUTHORS +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/LICENSE +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/README.md +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/__init__.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/cipher.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/compressor.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/cryptography.py +0 -0
- /eventsourcing-9.4.6/eventsourcing/py.typed → /eventsourcing-9.5.0a0/eventsourcing/dcb/__init__.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/dispatch.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/interface.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/projection.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/system.py +0 -0
- {eventsourcing-9.4.6 → eventsourcing-9.5.0a0}/eventsourcing/tests/__init__.py +0 -0
- {eventsourcing-9.4.6 → 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.
|
|
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 ::
|
|
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 '
|
|
669
|
+
"'log' is deprecated, use 'notification_log' instead",
|
|
667
670
|
DeprecationWarning,
|
|
668
671
|
stacklevel=2,
|
|
669
672
|
)
|
|
@@ -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] = ()
|