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.
Files changed (71) hide show
  1. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/PKG-INFO +9 -9
  2. python_event_sourcery-0.4.0/event_sourcery/event_sourcing/__init__.py +7 -0
  3. {python_event_sourcery-0.3.0/event_sourcery/aggregate → python_event_sourcery-0.4.0/event_sourcery/event_sourcing}/aggregate.py +2 -1
  4. {python_event_sourcery-0.3.0/event_sourcery/aggregate → python_event_sourcery-0.4.0/event_sourcery/event_sourcing}/repository.py +12 -5
  5. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/__init__.py +13 -13
  6. python_event_sourcery-0.4.0/event_sourcery/event_store/backend.py +177 -0
  7. python_event_sourcery-0.4.0/event_sourcery/event_store/dispatcher.py +64 -0
  8. python_event_sourcery-0.4.0/event_sourcery/event_store/event/__init__.py +38 -0
  9. python_event_sourcery-0.4.0/event_sourcery/event_store/event/_dto.py +137 -0
  10. python_event_sourcery-0.4.0/event_sourcery/event_store/event/_encryption.py +129 -0
  11. python_event_sourcery-0.4.0/event_sourcery/event_store/event/_registry.py +106 -0
  12. python_event_sourcery-0.4.0/event_sourcery/event_store/event/_serde.py +75 -0
  13. python_event_sourcery-0.4.0/event_sourcery/event_store/event_store.py +191 -0
  14. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/exceptions.py +18 -0
  15. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/in_memory.py +96 -93
  16. python_event_sourcery-0.4.0/event_sourcery/event_store/interfaces.py +101 -0
  17. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/outbox.py +3 -2
  18. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/stream_id.py +1 -1
  19. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/subscription.py +14 -19
  20. python_event_sourcery-0.4.0/event_sourcery/event_store/tenant_id.py +5 -0
  21. python_event_sourcery-0.4.0/event_sourcery/py.typed +0 -0
  22. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/read_model/projector.py +14 -13
  23. python_event_sourcery-0.4.0/event_sourcery_django/__init__.py +61 -0
  24. python_event_sourcery-0.4.0/event_sourcery_django/dto.py +83 -0
  25. python_event_sourcery-0.4.0/event_sourcery_django/event_store.py +151 -0
  26. python_event_sourcery-0.4.0/event_sourcery_django/migrations/0001_initial.py +95 -0
  27. python_event_sourcery-0.4.0/event_sourcery_django/migrations/__init__.py +0 -0
  28. python_event_sourcery-0.4.0/event_sourcery_django/models.py +86 -0
  29. python_event_sourcery-0.4.0/event_sourcery_django/outbox.py +51 -0
  30. python_event_sourcery-0.4.0/event_sourcery_django/py.typed +0 -0
  31. python_event_sourcery-0.4.0/event_sourcery_django/subscription.py +164 -0
  32. python_event_sourcery-0.4.0/event_sourcery_kurrentdb/__init__.py +69 -0
  33. {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/dto.py +17 -10
  34. {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/event_store.py +34 -25
  35. {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/outbox.py +17 -16
  36. python_event_sourcery-0.4.0/event_sourcery_kurrentdb/py.typed +0 -0
  37. python_event_sourcery-0.4.0/event_sourcery_kurrentdb/stream.py +62 -0
  38. {python_event_sourcery-0.3.0/event_sourcery_esdb → python_event_sourcery-0.4.0/event_sourcery_kurrentdb}/subscription.py +16 -11
  39. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/__init__.py +118 -0
  40. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/cursors_dao.py +23 -20
  41. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/dto.py +18 -0
  42. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/event_store.py +69 -45
  43. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/guid.py +7 -8
  44. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/jsonb.py +3 -3
  45. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/models/__init__.py +53 -0
  46. 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
  47. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/models/default.py +27 -0
  48. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery_sqlalchemy/outbox.py +26 -14
  49. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/py.typed +0 -0
  50. python_event_sourcery-0.4.0/event_sourcery_sqlalchemy/subscription.py +222 -0
  51. python_event_sourcery-0.4.0/pyproject.toml +136 -0
  52. python_event_sourcery-0.3.0/event_sourcery/aggregate/__init__.py +0 -7
  53. python_event_sourcery-0.3.0/event_sourcery/event_store/dispatcher.py +0 -35
  54. python_event_sourcery-0.3.0/event_sourcery/event_store/event/__init__.py +0 -26
  55. python_event_sourcery-0.3.0/event_sourcery/event_store/event/dto.py +0 -62
  56. python_event_sourcery-0.3.0/event_sourcery/event_store/event/registry.py +0 -41
  57. python_event_sourcery-0.3.0/event_sourcery/event_store/event/serde.py +0 -51
  58. python_event_sourcery-0.3.0/event_sourcery/event_store/event_store.py +0 -128
  59. python_event_sourcery-0.3.0/event_sourcery/event_store/factory.py +0 -52
  60. python_event_sourcery-0.3.0/event_sourcery/event_store/interfaces.py +0 -79
  61. python_event_sourcery-0.3.0/event_sourcery_esdb/__init__.py +0 -86
  62. python_event_sourcery-0.3.0/event_sourcery_esdb/stream.py +0 -65
  63. python_event_sourcery-0.3.0/event_sourcery_kombu/__init__.py +0 -155
  64. python_event_sourcery-0.3.0/event_sourcery_sqlalchemy/__init__.py +0 -84
  65. python_event_sourcery-0.3.0/event_sourcery_sqlalchemy/subscription.py +0 -33
  66. python_event_sourcery-0.3.0/pyproject.toml +0 -80
  67. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/LICENSE +0 -0
  68. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/__init__.py +0 -0
  69. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/event_store/versioning.py +0 -0
  70. {python_event_sourcery-0.3.0 → python_event_sourcery-0.4.0}/event_sourcery/read_model/__init__.py +0 -0
  71. {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
1
+ Metadata-Version: 2.3
2
2
  Name: python-event-sourcery
3
- Version: 0.3.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,<4.0
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
- Provides-Extra: cloudevents
14
+ Classifier: Programming Language :: Python :: 3.13
15
15
  Provides-Extra: django
16
- Provides-Extra: kombu
17
- Requires-Dist: SQLAlchemy (>=2.0)
18
- Requires-Dist: cloudevents ; extra == "cloudevents"
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: esdbclient (>=1.0b3)
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)
@@ -0,0 +1,7 @@
1
+ __all__ = [
2
+ "Aggregate",
3
+ "Repository",
4
+ ]
5
+
6
+ from event_sourcery.event_sourcing.aggregate import Aggregate
7
+ from event_sourcery.event_sourcing.repository import Repository
@@ -1,5 +1,6 @@
1
+ from collections.abc import Iterator
1
2
  from contextlib import contextmanager
2
- from typing import ClassVar, Iterator
3
+ from typing import ClassVar
3
4
 
4
5
  from event_sourcery.event_store import Event
5
6
 
@@ -1,8 +1,15 @@
1
+ from collections.abc import Iterator
1
2
  from contextlib import contextmanager
2
- from typing import Generic, Iterator, TypeVar, cast
3
+ from typing import Generic, TypeVar, cast
3
4
 
4
- from event_sourcery.aggregate import Aggregate
5
- from event_sourcery.event_store import Event, EventStore, Metadata, StreamId, StreamUUID
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
- Metadata.wrap(event, version)
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.publish(
58
+ self._event_store.append(
52
59
  *events,
53
60
  stream_id=stream_id,
54
61
  expected_version=old_version,
@@ -1,50 +1,50 @@
1
1
  __all__ = [
2
+ "DEFAULT_TENANT",
3
+ "NO_VERSIONING",
2
4
  "Backend",
3
- "BackendFactory",
5
+ "Context",
4
6
  "Dispatcher",
5
7
  "Entry",
6
8
  "Event",
7
9
  "EventRegistry",
8
10
  "EventStore",
9
11
  "ExplicitVersioning",
10
- "InMemoryBackendFactory",
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 exceptions, factory, interfaces, subscription
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.factory import (
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)