python-event-sourcery 0.3.0__tar.gz → 0.4.0__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.
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/PKG-INFO +9 -9
- python_event_sourcery-0.4.0/event_sourcery/event_sourcing/__init__.py +7 -0
- {python_event_sourcery-0.3.0/event_sourcery/aggregate → python_event_sourcery-0.4.0/event_sourcery/event_sourcing}/aggregate.py +2 -1
- {python_event_sourcery-0.3.0/event_sourcery/aggregate → python_event_sourcery-0.4.0/event_sourcery/event_sourcing}/repository.py +12 -5
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/__init__.py +13 -13
- python_event_sourcery-0.4.0/event_sourcery/event_store/backend.py +177 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/dispatcher.py +64 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event/__init__.py +38 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event/_dto.py +137 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event/_encryption.py +129 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event/_registry.py +106 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event/_serde.py +75 -0
- python_event_sourcery-0.4.0/event_sourcery/event_store/event_store.py +191 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/exceptions.py +18 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/in_memory.py +96 -93
- python_event_sourcery-0.4.0/event_sourcery/event_store/interfaces.py +101 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/outbox.py +3 -2
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/stream_id.py +1 -1
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/subscription.py +14 -19
- python_event_sourcery-0.4.0/event_sourcery/event_store/tenant_id.py +5 -0
- python_event_sourcery-0.4.0/event_sourcery/py.typed +0 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/read_model/projector.py +14 -13
- python_event_sourcery-0.4.0/event_sourcery_django/__init__.py +61 -0
- python_event_sourcery-0.4.0/event_sourcery_django/dto.py +83 -0
- python_event_sourcery-0.4.0/event_sourcery_django/event_store.py +151 -0
- python_event_sourcery-0.4.0/event_sourcery_django/migrations/0001_initial.py +95 -0
- python_event_sourcery-0.4.0/event_sourcery_django/migrations/__init__.py +0 -0
- python_event_sourcery-0.4.0/event_sourcery_django/models.py +86 -0
- python_event_sourcery-0.4.0/event_sourcery_django/outbox.py +51 -0
- python_event_sourcery-0.4.0/event_sourcery_django/py.typed +0 -0
- python_event_sourcery-0.4.0/event_sourcery_django/subscription.py +164 -0
- python_event_sourcery-0.4.0/event_sourcery_kurrentdb/__init__.py +69 -0
- {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/dto.py +17 -10
- {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/event_store.py +34 -25
- {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/outbox.py +17 -16
- python_event_sourcery-0.4.0/event_sourcery_kurrentdb/py.typed +0 -0
- python_event_sourcery-0.4.0/event_sourcery_kurrentdb/stream.py +62 -0
- {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/subscription.py +16 -11
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/__init__.py +118 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/cursors_dao.py +23 -20
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/dto.py +18 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/event_store.py +69 -45
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/guid.py +7 -8
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/jsonb.py +3 -3
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/models/__init__.py +53 -0
- python_event_sourcery-0.3.0/event_sourcery_sqlalchemy/models.py → python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/models/base.py +108 -63
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/models/default.py +27 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/outbox.py +26 -14
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/py.typed +0 -0
- python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/subscription.py +222 -0
- python_event_sourcery-0.4.0/pyproject.toml +136 -0
- python_event_sourcery-0.3.0/event_sourcery/aggregate/__init__.py +0 -7
- python_event_sourcery-0.3.0/event_sourcery/event_store/dispatcher.py +0 -35
- python_event_sourcery-0.3.0/event_sourcery/event_store/event/__init__.py +0 -26
- python_event_sourcery-0.3.0/event_sourcery/event_store/event/dto.py +0 -62
- python_event_sourcery-0.3.0/event_sourcery/event_store/event/registry.py +0 -41
- python_event_sourcery-0.3.0/event_sourcery/event_store/event/serde.py +0 -51
- python_event_sourcery-0.3.0/event_sourcery/event_store/event_store.py +0 -128
- python_event_sourcery-0.3.0/event_sourcery/event_store/factory.py +0 -52
- python_event_sourcery-0.3.0/event_sourcery/event_store/interfaces.py +0 -79
- python_event_sourcery-0.3.0/event_sourcery_esdb/__init__.py +0 -86
- python_event_sourcery-0.3.0/event_sourcery_esdb/stream.py +0 -65
- python_event_sourcery-0.3.0/event_sourcery_kombu/__init__.py +0 -155
- python_event_sourcery-0.3.0/event_sourcery_sqlalchemy/__init__.py +0 -84
- python_event_sourcery-0.3.0/event_sourcery_sqlalchemy/subscription.py +0 -33
- python_event_sourcery-0.3.0/pyproject.toml +0 -80
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/LICENSE +0 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/__init__.py +0 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/versioning.py +0 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/read_model/__init__.py +0 -0
- {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/read_model/cursors_dao.py +0 -0
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: python-event-sourcery
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary:
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Your Name
|
|
7
7
|
Author-email: you@example.com
|
|
8
|
-
Requires-Python: >=3.10
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
15
|
Provides-Extra: django
|
|
16
|
-
Provides-Extra:
|
|
17
|
-
|
|
18
|
-
Requires-Dist:
|
|
16
|
+
Provides-Extra: kurrentdb
|
|
17
|
+
Provides-Extra: sqlalchemy
|
|
18
|
+
Requires-Dist: SQLAlchemy (>=2.0) ; extra == "sqlalchemy"
|
|
19
19
|
Requires-Dist: django (>=4,<6) ; extra == "django"
|
|
20
|
-
Requires-Dist:
|
|
21
|
-
Requires-Dist: kombu ; extra == "kombu"
|
|
20
|
+
Requires-Dist: kurrentdbclient (>=1.0.6) ; extra == "kurrentdb"
|
|
22
21
|
Requires-Dist: more-itertools
|
|
22
|
+
Requires-Dist: psycopg2-binary (>2,<3)
|
|
23
23
|
Requires-Dist: pydantic (>=2.0,<3)
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
1
2
|
from contextlib import contextmanager
|
|
2
|
-
from typing import Generic,
|
|
3
|
+
from typing import Generic, TypeVar, cast
|
|
3
4
|
|
|
4
|
-
from event_sourcery.
|
|
5
|
-
from event_sourcery.event_store import
|
|
5
|
+
from event_sourcery.event_sourcing import Aggregate
|
|
6
|
+
from event_sourcery.event_store import (
|
|
7
|
+
Event,
|
|
8
|
+
EventStore,
|
|
9
|
+
StreamId,
|
|
10
|
+
StreamUUID,
|
|
11
|
+
WrappedEvent,
|
|
12
|
+
)
|
|
6
13
|
|
|
7
14
|
TAggregate = TypeVar("TAggregate", bound=Aggregate)
|
|
8
15
|
TEvent = TypeVar("TEvent", bound=Event)
|
|
@@ -41,14 +48,14 @@ class Repository(Generic[TAggregate]):
|
|
|
41
48
|
with aggregate.__persisting_changes__() as pending:
|
|
42
49
|
start_from = old_version + 1
|
|
43
50
|
events = [
|
|
44
|
-
|
|
51
|
+
WrappedEvent.wrap(event, version)
|
|
45
52
|
for version, event in enumerate(pending, start=start_from)
|
|
46
53
|
]
|
|
47
54
|
|
|
48
55
|
if not events:
|
|
49
56
|
return
|
|
50
57
|
|
|
51
|
-
self._event_store.
|
|
58
|
+
self._event_store.append(
|
|
52
59
|
*events,
|
|
53
60
|
stream_id=stream_id,
|
|
54
61
|
expected_version=old_version,
|
{python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/__init__.py
RENAMED
|
@@ -1,50 +1,50 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"DEFAULT_TENANT",
|
|
3
|
+
"NO_VERSIONING",
|
|
2
4
|
"Backend",
|
|
3
|
-
"
|
|
5
|
+
"Context",
|
|
4
6
|
"Dispatcher",
|
|
5
7
|
"Entry",
|
|
6
8
|
"Event",
|
|
7
9
|
"EventRegistry",
|
|
8
10
|
"EventStore",
|
|
9
11
|
"ExplicitVersioning",
|
|
10
|
-
"
|
|
12
|
+
"InMemoryBackend",
|
|
11
13
|
"Listener",
|
|
12
|
-
"Metadata",
|
|
13
|
-
"NO_VERSIONING",
|
|
14
14
|
"Position",
|
|
15
15
|
"RawEvent",
|
|
16
16
|
"Recorded",
|
|
17
17
|
"RecordedRaw",
|
|
18
18
|
"StreamId",
|
|
19
19
|
"StreamUUID",
|
|
20
|
+
"TenantId",
|
|
20
21
|
"TransactionalBackend",
|
|
21
22
|
"Versioning",
|
|
23
|
+
"WrappedEvent",
|
|
24
|
+
"backend",
|
|
22
25
|
"exceptions",
|
|
23
|
-
"factory",
|
|
24
26
|
"interfaces",
|
|
25
27
|
"subscription",
|
|
26
28
|
]
|
|
27
29
|
|
|
28
|
-
from event_sourcery.event_store import
|
|
30
|
+
from event_sourcery.event_store import backend, exceptions, interfaces, subscription
|
|
31
|
+
from event_sourcery.event_store.backend import Backend, TransactionalBackend
|
|
29
32
|
from event_sourcery.event_store.dispatcher import Dispatcher, Listener
|
|
30
33
|
from event_sourcery.event_store.event import (
|
|
34
|
+
Context,
|
|
31
35
|
Entry,
|
|
32
36
|
Event,
|
|
33
37
|
EventRegistry,
|
|
34
|
-
Metadata,
|
|
35
38
|
Position,
|
|
36
39
|
RawEvent,
|
|
37
40
|
Recorded,
|
|
38
41
|
RecordedRaw,
|
|
42
|
+
WrappedEvent,
|
|
39
43
|
)
|
|
40
44
|
from event_sourcery.event_store.event_store import EventStore
|
|
41
|
-
from event_sourcery.event_store.
|
|
42
|
-
Backend,
|
|
43
|
-
BackendFactory,
|
|
44
|
-
TransactionalBackend,
|
|
45
|
-
)
|
|
46
|
-
from event_sourcery.event_store.in_memory import InMemoryBackendFactory
|
|
45
|
+
from event_sourcery.event_store.in_memory import InMemoryBackend
|
|
47
46
|
from event_sourcery.event_store.stream_id import StreamId, StreamUUID
|
|
47
|
+
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
|
|
48
48
|
from event_sourcery.event_store.versioning import (
|
|
49
49
|
NO_VERSIONING,
|
|
50
50
|
ExplicitVersioning,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterator
|
|
2
|
+
from contextlib import AbstractContextManager
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import NoReturn, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from event_sourcery.event_store import subscription
|
|
9
|
+
from event_sourcery.event_store.dispatcher import Dispatcher, Listeners
|
|
10
|
+
from event_sourcery.event_store.event import (
|
|
11
|
+
Encryption,
|
|
12
|
+
EventRegistry,
|
|
13
|
+
NoEncryptionStrategy,
|
|
14
|
+
NoKeyStorageStrategy,
|
|
15
|
+
RawEvent,
|
|
16
|
+
RecordedRaw,
|
|
17
|
+
Serde,
|
|
18
|
+
)
|
|
19
|
+
from event_sourcery.event_store.event_store import EventStore
|
|
20
|
+
from event_sourcery.event_store.interfaces import (
|
|
21
|
+
EncryptionKeyStorageStrategy,
|
|
22
|
+
EncryptionStrategy,
|
|
23
|
+
OutboxFiltererStrategy,
|
|
24
|
+
OutboxStorageStrategy,
|
|
25
|
+
StorageStrategy,
|
|
26
|
+
SubscriptionStrategy,
|
|
27
|
+
)
|
|
28
|
+
from event_sourcery.event_store.outbox import Outbox
|
|
29
|
+
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NoProviderConfigured(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def no_filter(entry: RawEvent) -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NoOutboxStorageStrategy(OutboxStorageStrategy):
|
|
41
|
+
def outbox_entries(
|
|
42
|
+
self, limit: int
|
|
43
|
+
) -> Iterator[AbstractContextManager[RecordedRaw]]:
|
|
44
|
+
return iter([])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
T = TypeVar("T")
|
|
48
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
49
|
+
|
|
50
|
+
_Provider = Callable[["_Container"], T]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def singleton(provider: _Provider[T]) -> _Provider[T]:
|
|
54
|
+
result: T | None = None
|
|
55
|
+
|
|
56
|
+
@wraps(provider)
|
|
57
|
+
def _wrapper(container: "_Container") -> T:
|
|
58
|
+
nonlocal result
|
|
59
|
+
if result is not None:
|
|
60
|
+
return result
|
|
61
|
+
result = provider(container)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
return _wrapper
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def not_configured(error_message: str) -> _Provider[T]:
|
|
68
|
+
def _raise(container: "_Container") -> NoReturn:
|
|
69
|
+
raise NoProviderConfigured(error_message) # pragma: no cover
|
|
70
|
+
|
|
71
|
+
return _raise
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Container:
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self.providers: dict[type, _Provider] = {}
|
|
77
|
+
|
|
78
|
+
def __getitem__(self, _type: type[T]) -> T:
|
|
79
|
+
return cast(_Provider[T], self.providers[_type])(self)
|
|
80
|
+
|
|
81
|
+
def __setitem__(self, _type: type[T], value: T | _Provider[T]) -> None:
|
|
82
|
+
self.providers[_type] = (
|
|
83
|
+
cast(_Provider[T], lambda _: value)
|
|
84
|
+
if isinstance(value, _type)
|
|
85
|
+
else cast(_Provider[T], value)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def get(self, _type: type[T], default: T | None = None) -> T | None:
|
|
89
|
+
if _type not in self.providers:
|
|
90
|
+
return default
|
|
91
|
+
return self[_type]
|
|
92
|
+
|
|
93
|
+
def copy(self) -> Self:
|
|
94
|
+
new: Self = self.__class__()
|
|
95
|
+
new.providers.update(self.providers.copy())
|
|
96
|
+
return new
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Backend(_Container):
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self[TenantId] = DEFAULT_TENANT
|
|
103
|
+
self[EventRegistry] = EventRegistry()
|
|
104
|
+
self[EncryptionStrategy] = NoEncryptionStrategy()
|
|
105
|
+
self[EncryptionKeyStorageStrategy] = (
|
|
106
|
+
lambda c: NoKeyStorageStrategy().scoped_for_tenant(c[TenantId])
|
|
107
|
+
)
|
|
108
|
+
self[Encryption] = lambda c: Encryption(
|
|
109
|
+
registry=c[EventRegistry],
|
|
110
|
+
strategy=c[EncryptionStrategy],
|
|
111
|
+
key_storage=c[EncryptionKeyStorageStrategy],
|
|
112
|
+
)
|
|
113
|
+
self[Serde] = lambda c: Serde(
|
|
114
|
+
registry=c[EventRegistry],
|
|
115
|
+
encryption=c[Encryption],
|
|
116
|
+
)
|
|
117
|
+
self[StorageStrategy] = not_configured(
|
|
118
|
+
"Use one of pyES backends: SQLAlchemy, Django or KurrentDB",
|
|
119
|
+
)
|
|
120
|
+
self[EventStore] = lambda c: EventStore(
|
|
121
|
+
storage_strategy=c[StorageStrategy],
|
|
122
|
+
serde=c[Serde],
|
|
123
|
+
)
|
|
124
|
+
self[Outbox] = lambda c: Outbox(
|
|
125
|
+
strategy=c[OutboxStorageStrategy],
|
|
126
|
+
serde=c[Serde],
|
|
127
|
+
)
|
|
128
|
+
self[OutboxStorageStrategy] = lambda _: NoOutboxStorageStrategy()
|
|
129
|
+
self[SubscriptionStrategy] = not_configured(
|
|
130
|
+
"Use one of pyES backends: SQLAlchemy, Django or KurrentDB",
|
|
131
|
+
)
|
|
132
|
+
self[subscription.PositionPhase] = lambda c: subscription.SubscriptionBuilder(
|
|
133
|
+
c[Serde],
|
|
134
|
+
c[SubscriptionStrategy],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def event_store(self) -> EventStore:
|
|
139
|
+
return self[EventStore]
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def outbox(self) -> Outbox:
|
|
143
|
+
return self[Outbox]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def subscriber(self) -> subscription.PositionPhase:
|
|
147
|
+
return self[subscription.PositionPhase]
|
|
148
|
+
|
|
149
|
+
def in_tenant_mode(self, tenant_id: TenantId) -> Self:
|
|
150
|
+
in_tenant_mode = self.copy()
|
|
151
|
+
in_tenant_mode[TenantId] = tenant_id
|
|
152
|
+
return in_tenant_mode
|
|
153
|
+
|
|
154
|
+
def with_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
|
|
155
|
+
raise NotImplementedError()
|
|
156
|
+
|
|
157
|
+
def with_encryption(
|
|
158
|
+
self,
|
|
159
|
+
strategy: EncryptionStrategy,
|
|
160
|
+
key_storage: EncryptionKeyStorageStrategy,
|
|
161
|
+
) -> Self:
|
|
162
|
+
self[EncryptionStrategy] = strategy
|
|
163
|
+
self[EncryptionKeyStorageStrategy] = lambda c: key_storage.scoped_for_tenant(
|
|
164
|
+
c[TenantId],
|
|
165
|
+
)
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TransactionalBackend(Backend):
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
super().__init__()
|
|
172
|
+
self[Listeners] = singleton(lambda _: Listeners())
|
|
173
|
+
self[Dispatcher] = lambda c: Dispatcher(c[Serde], c[Listeners])
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def in_transaction(self) -> Listeners:
|
|
177
|
+
return cast(Listeners, self[Listeners])
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from itertools import chain
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from event_sourcery.event_store.event import (
|
|
6
|
+
Event,
|
|
7
|
+
Position,
|
|
8
|
+
RecordedRaw,
|
|
9
|
+
Serde,
|
|
10
|
+
WrappedEvent,
|
|
11
|
+
)
|
|
12
|
+
from event_sourcery.event_store.stream_id import StreamId
|
|
13
|
+
from event_sourcery.event_store.tenant_id import TenantId
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Listener(Protocol):
|
|
17
|
+
def __call__(
|
|
18
|
+
self,
|
|
19
|
+
wrapped_event: WrappedEvent,
|
|
20
|
+
stream_id: StreamId,
|
|
21
|
+
tenant_id: TenantId,
|
|
22
|
+
position: Position,
|
|
23
|
+
) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Listeners:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._listeners: dict[type[Event], set[Listener]] = {}
|
|
29
|
+
|
|
30
|
+
def __getitem__(self, event_type: type[Event]) -> Iterator[Listener]:
|
|
31
|
+
return chain(
|
|
32
|
+
*(
|
|
33
|
+
listeners
|
|
34
|
+
for registered_to, listeners in self._listeners.items()
|
|
35
|
+
if issubclass(event_type, registered_to)
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def register(self, listener: Listener, to: type[Event]) -> None:
|
|
40
|
+
if to not in self._listeners:
|
|
41
|
+
self._listeners[to] = set()
|
|
42
|
+
self._listeners[to].add(listener)
|
|
43
|
+
|
|
44
|
+
def remove(self, listener: Listener, to: type[Event]) -> None:
|
|
45
|
+
if to in self._listeners and listener in self._listeners[to]:
|
|
46
|
+
self._listeners[to].remove(listener)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Dispatcher:
|
|
50
|
+
def __init__(self, serde: Serde, listeners: Listeners) -> None:
|
|
51
|
+
self._serde = serde
|
|
52
|
+
self._listeners = listeners
|
|
53
|
+
|
|
54
|
+
def dispatch(self, *raws: RecordedRaw) -> None:
|
|
55
|
+
for raw in raws:
|
|
56
|
+
record = self._serde.deserialize_record(raw)
|
|
57
|
+
event_type = record.wrapped_event.event.__class__
|
|
58
|
+
for listener in self._listeners[event_type]:
|
|
59
|
+
listener(
|
|
60
|
+
record.wrapped_event,
|
|
61
|
+
record.stream_id,
|
|
62
|
+
record.tenant_id,
|
|
63
|
+
record.position,
|
|
64
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"Context",
|
|
3
|
+
"DataSubject",
|
|
4
|
+
"Encrypted",
|
|
5
|
+
"Encryption",
|
|
6
|
+
"Entry",
|
|
7
|
+
"Event",
|
|
8
|
+
"EventRegistry",
|
|
9
|
+
"NoEncryptionStrategy",
|
|
10
|
+
"NoKeyStorageStrategy",
|
|
11
|
+
"Position",
|
|
12
|
+
"RawEvent",
|
|
13
|
+
"Recorded",
|
|
14
|
+
"RecordedRaw",
|
|
15
|
+
"Serde",
|
|
16
|
+
"WrappedEvent",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from event_sourcery.event_store.event._dto import (
|
|
21
|
+
Context,
|
|
22
|
+
DataSubject,
|
|
23
|
+
Encrypted,
|
|
24
|
+
Entry,
|
|
25
|
+
Event,
|
|
26
|
+
Position,
|
|
27
|
+
RawEvent,
|
|
28
|
+
Recorded,
|
|
29
|
+
RecordedRaw,
|
|
30
|
+
WrappedEvent,
|
|
31
|
+
)
|
|
32
|
+
from event_sourcery.event_store.event._encryption import (
|
|
33
|
+
Encryption,
|
|
34
|
+
NoEncryptionStrategy,
|
|
35
|
+
NoKeyStorageStrategy,
|
|
36
|
+
)
|
|
37
|
+
from event_sourcery.event_store.event._registry import EventRegistry
|
|
38
|
+
from event_sourcery.event_store.event._serde import Serde
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Generic, TypeAlias, TypeVar
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from event_sourcery.event_store.stream_id import StreamId
|
|
10
|
+
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
|
|
11
|
+
|
|
12
|
+
DataSubject = object()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass(frozen=True)
|
|
16
|
+
class RawEvent(BaseModel):
|
|
17
|
+
uuid: UUID
|
|
18
|
+
stream_id: StreamId
|
|
19
|
+
created_at: datetime
|
|
20
|
+
name: str
|
|
21
|
+
data: dict[str, Any]
|
|
22
|
+
context: dict[str, Any]
|
|
23
|
+
version: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Position: TypeAlias = int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclasses.dataclass(frozen=True)
|
|
30
|
+
class RecordedRaw(BaseModel):
|
|
31
|
+
entry: RawEvent
|
|
32
|
+
position: Position
|
|
33
|
+
tenant_id: TenantId = DEFAULT_TENANT
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Event(BaseModel, extra="forbid"):
|
|
37
|
+
"""Base class for all events.
|
|
38
|
+
|
|
39
|
+
Example usage:
|
|
40
|
+
```
|
|
41
|
+
class OrderCancelled(Event):
|
|
42
|
+
order_id: OrderId
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TEvent = TypeVar("TEvent", bound=Event)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Context(BaseModel, extra="allow"):
|
|
51
|
+
correlation_id: UUID | None = None
|
|
52
|
+
causation_id: UUID | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass()
|
|
56
|
+
class WrappedEvent(Generic[TEvent]):
|
|
57
|
+
"""Wrapper for events with all relevant metadata.
|
|
58
|
+
|
|
59
|
+
Returned from EventStore when loading events from a stream.
|
|
60
|
+
|
|
61
|
+
Example usage:
|
|
62
|
+
```
|
|
63
|
+
class OrderCancelled(Event):
|
|
64
|
+
order_id: OrderId
|
|
65
|
+
|
|
66
|
+
event = OrderCancelled(order_id=OrderId("#123"))
|
|
67
|
+
wrapped_event = WrappedEvent.wrap(event, version=1)
|
|
68
|
+
```
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
event: TEvent
|
|
72
|
+
version: int | None
|
|
73
|
+
uuid: UUID = dataclasses.field(default_factory=uuid4)
|
|
74
|
+
created_at: datetime = dataclasses.field(
|
|
75
|
+
default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
|
|
76
|
+
)
|
|
77
|
+
context: Context = dataclasses.field(default_factory=Context)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def wrap(cls, event: TEvent, version: int | None) -> "WrappedEvent[TEvent]":
|
|
81
|
+
return WrappedEvent[TEvent](event=event, version=version)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclasses.dataclass(frozen=True)
|
|
85
|
+
class Entry:
|
|
86
|
+
wrapped_event: WrappedEvent[Event]
|
|
87
|
+
stream_id: StreamId
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclasses.dataclass(frozen=True)
|
|
91
|
+
class Recorded(Entry):
|
|
92
|
+
"""DTO containing events saved in the event store.
|
|
93
|
+
|
|
94
|
+
It contains position of the event in the event store and tenant id.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
position: Position
|
|
98
|
+
tenant_id: TenantId = DEFAULT_TENANT
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Encrypted:
|
|
103
|
+
"""Field annotation for marking event fields that should be encrypted.
|
|
104
|
+
|
|
105
|
+
This annotation is used in combination with DataSubject to implement
|
|
106
|
+
crypto-shredding, allowing for GDPR-compliant data removal in event-sourced systems.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
mask_value: Value to display when the data is shredded (deleted).
|
|
110
|
+
Must match the field type.
|
|
111
|
+
subject_field: Optional. Name of the field containing the subject ID for this
|
|
112
|
+
encrypted field. If not provided, the field marked with
|
|
113
|
+
DataSubject will be used.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
```python
|
|
117
|
+
class UserRegistered(Event):
|
|
118
|
+
user_id: Annotated[str, DataSubject]
|
|
119
|
+
email: Annotated[str, Encrypted(mask_value="[REDACTED]")]
|
|
120
|
+
# Custom subject field
|
|
121
|
+
other_data: Annotated[
|
|
122
|
+
str,
|
|
123
|
+
Encrypted(
|
|
124
|
+
mask_value="[HIDDEN]",
|
|
125
|
+
subject_field="alternative_id",
|
|
126
|
+
),
|
|
127
|
+
]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Note:
|
|
131
|
+
- Every event with encrypted fields must have exactly one DataSubject field.
|
|
132
|
+
- The mask_value type must match the field type.
|
|
133
|
+
- After shredding, the field will always return its mask_value.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
mask_value: Any
|
|
137
|
+
subject_field: str | None = None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import UserDict
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from event_sourcery.event_store.event._registry import EventRegistry
|
|
10
|
+
from event_sourcery.event_store.exceptions import KeyNotFoundError, NoSubjectIdFound
|
|
11
|
+
from event_sourcery.event_store.interfaces import (
|
|
12
|
+
EncryptionKeyStorageStrategy,
|
|
13
|
+
EncryptionStrategy,
|
|
14
|
+
)
|
|
15
|
+
from event_sourcery.event_store.stream_id import StreamId
|
|
16
|
+
from event_sourcery.event_store.tenant_id import TenantId
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoEncryptionStrategy(EncryptionStrategy):
|
|
20
|
+
def encrypt(self, data: Any, key: bytes) -> str:
|
|
21
|
+
raise NotImplementedError("NoEncryptionStrategy does not support encryption.")
|
|
22
|
+
|
|
23
|
+
def decrypt(self, data: str, key: bytes) -> Any:
|
|
24
|
+
raise NotImplementedError("NoEncryptionStrategy does not support decryption.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoKeyStorageStrategy(EncryptionKeyStorageStrategy):
|
|
28
|
+
def get(self, subject_id: str) -> bytes | None:
|
|
29
|
+
raise NotImplementedError(
|
|
30
|
+
"NoKeyStorageStrategy does not support key retrieval."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def store(self, subject_id: str, key: bytes) -> None:
|
|
34
|
+
raise NotImplementedError("NoKeyStorageStrategy does not support key storage.")
|
|
35
|
+
|
|
36
|
+
def delete(self, subject_id: str) -> None:
|
|
37
|
+
raise NotImplementedError("NoKeyStorageStrategy does not support key deletion.")
|
|
38
|
+
|
|
39
|
+
def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NestedDict(UserDict):
|
|
44
|
+
def __getitem__(self, item: str) -> Any:
|
|
45
|
+
match item.split(".", 1):
|
|
46
|
+
case [prefix, key]:
|
|
47
|
+
return NestedDict(self.data[prefix])[key]
|
|
48
|
+
case _:
|
|
49
|
+
return self.data[item]
|
|
50
|
+
|
|
51
|
+
def __setitem__(self, item: str, value: Any) -> None:
|
|
52
|
+
match item.rsplit(".", 1):
|
|
53
|
+
case [prefix, key]:
|
|
54
|
+
self[prefix][key] = value
|
|
55
|
+
case _:
|
|
56
|
+
self.data[item] = value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Encryption:
|
|
61
|
+
"""
|
|
62
|
+
Integrates encryption logic and key management.
|
|
63
|
+
Uses the KeyStorageStrategy and EncryptionStrategy interfaces.
|
|
64
|
+
Responsible for:
|
|
65
|
+
- Retrieving the key for a given subject_id
|
|
66
|
+
- Encrypting and decrypting data
|
|
67
|
+
- Masking data when the key is not available (crypto-shredding)
|
|
68
|
+
- Processing event encryption/decryption with field annotations
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
registry: EventRegistry
|
|
72
|
+
strategy: EncryptionStrategy
|
|
73
|
+
key_storage: EncryptionKeyStorageStrategy
|
|
74
|
+
|
|
75
|
+
def encrypt(self, event: BaseModel, stream_id: StreamId) -> dict[str, Any]:
|
|
76
|
+
event_type = type(event)
|
|
77
|
+
data = NestedDict(event.model_dump(mode="json"))
|
|
78
|
+
for field_name in self.registry.encrypted_fields(of=event_type).keys():
|
|
79
|
+
subject_field = self.registry.subject_filed(field_name, of=event_type)
|
|
80
|
+
subject_id = (
|
|
81
|
+
subject_field and getattr(event, subject_field)
|
|
82
|
+
) or stream_id.name
|
|
83
|
+
|
|
84
|
+
if subject_id is None:
|
|
85
|
+
raise NoSubjectIdFound(stream_id)
|
|
86
|
+
data[field_name] = self._encrypt_value(data[field_name], subject_id)
|
|
87
|
+
return data.data
|
|
88
|
+
|
|
89
|
+
def decrypt(
|
|
90
|
+
self,
|
|
91
|
+
event_type: type[BaseModel],
|
|
92
|
+
raw: dict[str, Any],
|
|
93
|
+
stream_id: StreamId,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
data = NestedDict(raw)
|
|
96
|
+
encrypted_fields = self.registry.encrypted_fields(of=event_type)
|
|
97
|
+
for field_name, encrypted_config in encrypted_fields.items():
|
|
98
|
+
subject_field = self.registry.subject_filed(field_name, of=event_type)
|
|
99
|
+
subject_id = data[subject_field] if subject_field else stream_id.name or ""
|
|
100
|
+
data[field_name] = self._decrypt_value(
|
|
101
|
+
data[field_name],
|
|
102
|
+
subject_id,
|
|
103
|
+
encrypted_config.mask_value,
|
|
104
|
+
)
|
|
105
|
+
return data.data
|
|
106
|
+
|
|
107
|
+
def _decrypt_value(self, value: str, subject_id: str, mask_value: Any) -> Any:
|
|
108
|
+
key = self.key_storage.get(subject_id)
|
|
109
|
+
if key is None:
|
|
110
|
+
return mask_value
|
|
111
|
+
decrypted = self.strategy.decrypt(value, key)
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(decrypted)
|
|
114
|
+
except (json.JSONDecodeError, TypeError):
|
|
115
|
+
return decrypted
|
|
116
|
+
|
|
117
|
+
def _encrypt_value(self, value: Any, subject_id: str) -> str:
|
|
118
|
+
key = self.key_storage.get(subject_id)
|
|
119
|
+
if key is None:
|
|
120
|
+
raise KeyNotFoundError(subject_id)
|
|
121
|
+
match value:
|
|
122
|
+
case str():
|
|
123
|
+
serialized = value
|
|
124
|
+
case _:
|
|
125
|
+
serialized = json.dumps(value)
|
|
126
|
+
return self.strategy.encrypt(serialized, key)
|
|
127
|
+
|
|
128
|
+
def shred(self, subject_id: str) -> None:
|
|
129
|
+
self.key_storage.delete(subject_id)
|