python-event-sourcery 0.4.0a13__tar.gz → 0.4.0a15__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 (56) hide show
  1. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/PKG-INFO +2 -1
  2. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/__init__.py +6 -5
  3. python_event_sourcery-0.4.0a15/event_sourcery/event_store/event/__init__.py +32 -0
  4. python_event_sourcery-0.4.0a13/event_sourcery/event_store/event/dto.py → python_event_sourcery-0.4.0a15/event_sourcery/event_store/event/_dto.py +51 -10
  5. python_event_sourcery-0.4.0a15/event_sourcery/event_store/event/_encryption.py +135 -0
  6. python_event_sourcery-0.4.0a15/event_sourcery/event_store/event/_registry.py +104 -0
  7. python_event_sourcery-0.4.0a13/event_sourcery/event_store/event/serde.py → python_event_sourcery-0.4.0a15/event_sourcery/event_store/event/_serde.py +25 -8
  8. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/event_store.py +1 -1
  9. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/exceptions.py +14 -0
  10. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/factory.py +10 -0
  11. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/in_memory.py +46 -5
  12. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/interfaces.py +30 -1
  13. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/stream_id.py +1 -1
  14. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/subscription.py +2 -2
  15. python_event_sourcery-0.4.0a15/event_sourcery_django/__init__.py +97 -0
  16. python_event_sourcery-0.4.0a15/event_sourcery_django/dto.py +83 -0
  17. python_event_sourcery-0.4.0a15/event_sourcery_django/event_store.py +151 -0
  18. python_event_sourcery-0.4.0a15/event_sourcery_django/migrations/0001_initial.py +95 -0
  19. python_event_sourcery-0.4.0a15/event_sourcery_django/models.py +86 -0
  20. python_event_sourcery-0.4.0a15/event_sourcery_django/outbox.py +51 -0
  21. python_event_sourcery-0.4.0a15/event_sourcery_django/subscription.py +164 -0
  22. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/__init__.py +20 -3
  23. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/event_store.py +8 -5
  24. python_event_sourcery-0.4.0a15/event_sourcery_esdb/py.typed +0 -0
  25. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/stream.py +1 -1
  26. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/__init__.py +25 -6
  27. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/models.py +2 -2
  28. python_event_sourcery-0.4.0a15/event_sourcery_sqlalchemy/py.typed +0 -0
  29. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/pyproject.toml +12 -1
  30. python_event_sourcery-0.4.0a13/event_sourcery/event_store/event/__init__.py +0 -26
  31. python_event_sourcery-0.4.0a13/event_sourcery/event_store/event/registry.py +0 -48
  32. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/LICENSE +0 -0
  33. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/__init__.py +0 -0
  34. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/aggregate/__init__.py +0 -0
  35. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/aggregate/aggregate.py +0 -0
  36. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/aggregate/repository.py +0 -0
  37. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/dispatcher.py +0 -0
  38. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/outbox.py +0 -0
  39. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/tenant_id.py +0 -0
  40. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/event_store/versioning.py +0 -0
  41. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/py.typed +0 -0
  42. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/read_model/__init__.py +0 -0
  43. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/read_model/cursors_dao.py +0 -0
  44. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery/read_model/projector.py +0 -0
  45. /python_event_sourcery-0.4.0a13/event_sourcery_esdb/py.typed → /python_event_sourcery-0.4.0a15/event_sourcery_django/migrations/__init__.py +0 -0
  46. {python_event_sourcery-0.4.0a13/event_sourcery_sqlalchemy → python_event_sourcery-0.4.0a15/event_sourcery_django}/py.typed +0 -0
  47. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/dto.py +0 -0
  48. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/outbox.py +0 -0
  49. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_esdb/subscription.py +0 -0
  50. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/cursors_dao.py +0 -0
  51. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/dto.py +0 -0
  52. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/event_store.py +0 -0
  53. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/guid.py +0 -0
  54. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/jsonb.py +0 -0
  55. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/outbox.py +0 -0
  56. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a15}/event_sourcery_sqlalchemy/subscription.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-event-sourcery
3
- Version: 0.4.0a13
3
+ Version: 0.4.0a15
4
4
  Summary:
5
5
  License: MIT
6
6
  Author: Your Name
@@ -19,4 +19,5 @@ Requires-Dist: SQLAlchemy (>=2.0) ; extra == "sqlalchemy"
19
19
  Requires-Dist: django (>=4,<6) ; extra == "django"
20
20
  Requires-Dist: esdbclient (>=1.0b3) ; extra == "esdb"
21
21
  Requires-Dist: more-itertools
22
+ Requires-Dist: psycopg2-binary (>2,<3)
22
23
  Requires-Dist: pydantic (>=2.0,<3)
@@ -1,30 +1,31 @@
1
1
  __all__ = [
2
+ "DEFAULT_TENANT",
3
+ "NO_VERSIONING",
2
4
  "Backend",
3
5
  "BackendFactory",
6
+ "Context",
4
7
  "Dispatcher",
5
8
  "Entry",
6
9
  "Event",
7
- "Context",
8
10
  "EventRegistry",
9
11
  "EventStore",
10
12
  "ExplicitVersioning",
11
13
  "InMemoryBackendFactory",
12
14
  "Listener",
13
- "WrappedEvent",
14
- "NO_VERSIONING",
15
15
  "Position",
16
16
  "RawEvent",
17
17
  "Recorded",
18
18
  "RecordedRaw",
19
19
  "StreamId",
20
20
  "StreamUUID",
21
+ "TenantId",
21
22
  "TransactionalBackend",
22
23
  "Versioning",
24
+ "WrappedEvent",
23
25
  "exceptions",
24
26
  "factory",
25
27
  "interfaces",
26
28
  "subscription",
27
- "TenantId",
28
29
  ]
29
30
 
30
31
  from event_sourcery.event_store import exceptions, factory, interfaces, subscription
@@ -48,7 +49,7 @@ from event_sourcery.event_store.factory import (
48
49
  )
49
50
  from event_sourcery.event_store.in_memory import InMemoryBackendFactory
50
51
  from event_sourcery.event_store.stream_id import StreamId, StreamUUID
51
- from event_sourcery.event_store.tenant_id import TenantId
52
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
52
53
  from event_sourcery.event_store.versioning import (
53
54
  NO_VERSIONING,
54
55
  ExplicitVersioning,
@@ -0,0 +1,32 @@
1
+ __all__ = [
2
+ "Context",
3
+ "DataSubject",
4
+ "Encrypted",
5
+ "Encryption",
6
+ "Entry",
7
+ "Event",
8
+ "EventRegistry",
9
+ "Position",
10
+ "RawEvent",
11
+ "Recorded",
12
+ "RecordedRaw",
13
+ "Serde",
14
+ "WrappedEvent",
15
+ ]
16
+
17
+
18
+ from event_sourcery.event_store.event._dto import (
19
+ Context,
20
+ DataSubject,
21
+ Encrypted,
22
+ Entry,
23
+ Event,
24
+ Position,
25
+ RawEvent,
26
+ Recorded,
27
+ RecordedRaw,
28
+ WrappedEvent,
29
+ )
30
+ from event_sourcery.event_store.event._encryption import Encryption
31
+ from event_sourcery.event_store.event._registry import EventRegistry
32
+ from event_sourcery.event_store.event._serde import Serde
@@ -1,14 +1,16 @@
1
1
  import dataclasses
2
+ from dataclasses import dataclass
2
3
  from datetime import datetime, timezone
3
- from typing import Any, ClassVar, Generic, TypeAlias, TypeVar
4
+ from typing import Any, Generic, TypeAlias, TypeVar
4
5
  from uuid import UUID, uuid4
5
6
 
6
7
  from pydantic import BaseModel
7
8
 
8
- from event_sourcery.event_store.event.registry import EventRegistry
9
9
  from event_sourcery.event_store.stream_id import StreamId
10
10
  from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
11
11
 
12
+ DataSubject = object()
13
+
12
14
 
13
15
  @dataclasses.dataclass(frozen=True)
14
16
  class RawEvent(BaseModel):
@@ -16,8 +18,8 @@ class RawEvent(BaseModel):
16
18
  stream_id: StreamId
17
19
  created_at: datetime
18
20
  name: str
19
- data: dict
20
- context: dict
21
+ data: dict[str, Any]
22
+ context: dict[str, Any]
21
23
  version: int | None = None
22
24
 
23
25
 
@@ -41,11 +43,6 @@ class Event(BaseModel, extra="forbid"):
41
43
  ```
42
44
  """
43
45
 
44
- __registry__: ClassVar = EventRegistry()
45
-
46
- def __init_subclass__(cls, **kwargs: Any) -> None:
47
- cls.__registry__.add(cls)
48
-
49
46
 
50
47
  TEvent = TypeVar("TEvent", bound=Event)
51
48
 
@@ -86,11 +83,55 @@ class WrappedEvent(Generic[TEvent]):
86
83
 
87
84
  @dataclasses.dataclass(frozen=True)
88
85
  class Entry:
89
- wrapped_event: WrappedEvent
86
+ wrapped_event: WrappedEvent[Event]
90
87
  stream_id: StreamId
91
88
 
92
89
 
93
90
  @dataclasses.dataclass(frozen=True)
94
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
+
95
97
  position: Position
96
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,135 @@
1
+ import json
2
+ from collections import UserDict
3
+ from dataclasses import dataclass, field, replace
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 = field(default_factory=EventRegistry)
72
+ strategy: EncryptionStrategy = field(default_factory=NoEncryptionStrategy)
73
+ key_storage: EncryptionKeyStorageStrategy = field(
74
+ default_factory=NoKeyStorageStrategy
75
+ )
76
+
77
+ def encrypt(self, event: BaseModel, stream_id: StreamId) -> dict[str, Any]:
78
+ event_type = type(event)
79
+ data = NestedDict(event.model_dump(mode="json"))
80
+ for field_name in self.registry.encrypted_fields(of=event_type).keys():
81
+ subject_field = self.registry.subject_filed(field_name, of=event_type)
82
+ subject_id = (
83
+ subject_field and getattr(event, subject_field)
84
+ ) or stream_id.name
85
+
86
+ if subject_id is None:
87
+ raise NoSubjectIdFound(stream_id)
88
+ data[field_name] = self._encrypt_value(data[field_name], subject_id)
89
+ return data.data
90
+
91
+ def decrypt(
92
+ self,
93
+ event_type: type[BaseModel],
94
+ raw: dict[str, Any],
95
+ stream_id: StreamId,
96
+ ) -> dict[str, Any]:
97
+ data = NestedDict(raw)
98
+ encrypted_fields = self.registry.encrypted_fields(of=event_type)
99
+ for field_name, encrypted_config in encrypted_fields.items():
100
+ subject_field = self.registry.subject_filed(field_name, of=event_type)
101
+ subject_id = data[subject_field] if subject_field else stream_id.name or ""
102
+ data[field_name] = self._decrypt_value(
103
+ data[field_name],
104
+ subject_id,
105
+ encrypted_config.mask_value,
106
+ )
107
+ return data.data
108
+
109
+ def _decrypt_value(self, value: str, subject_id: str, mask_value: Any) -> Any:
110
+ key = self.key_storage.get(subject_id)
111
+ if key is None:
112
+ return mask_value
113
+ decrypted = self.strategy.decrypt(value, key)
114
+
115
+ try:
116
+ return json.loads(decrypted)
117
+ except (json.JSONDecodeError, TypeError):
118
+ return decrypted
119
+
120
+ def _encrypt_value(self, value: Any, subject_id: str) -> str:
121
+ key = self.key_storage.get(subject_id)
122
+ if key is None:
123
+ raise KeyNotFoundError(subject_id)
124
+ match value:
125
+ case str():
126
+ serialized = value
127
+ case _:
128
+ serialized = json.dumps(value)
129
+ return self.strategy.encrypt(serialized, key)
130
+
131
+ def shred(self, subject_id: str) -> None:
132
+ self.key_storage.delete(subject_id)
133
+
134
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
135
+ return replace(self, key_storage=self.key_storage.scoped_for_tenant(tenant_id))
@@ -0,0 +1,104 @@
1
+ import inspect
2
+ from typing import Annotated, TypeAlias, get_args, get_origin, get_type_hints
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from event_sourcery.event_store.event._dto import DataSubject, Encrypted, Event
7
+ from event_sourcery.event_store.exceptions import (
8
+ ClassModuleUnavailable,
9
+ DuplicatedEvent,
10
+ )
11
+
12
+ TEvent: TypeAlias = BaseModel
13
+
14
+
15
+ def event_name(cls: type) -> str:
16
+ if name := getattr(cls, "__event_name__", ""):
17
+ return name
18
+
19
+ event_module = inspect.getmodule(cls)
20
+ if event_module is None: # pragma: no cover
21
+ raise ClassModuleUnavailable
22
+ return f"{event_module.__name__}.{cls.__qualname__}"
23
+
24
+
25
+ def get_encrypted_fields(of: type) -> dict[str, Encrypted]:
26
+ fields = {}
27
+ for field, hint in get_type_hints(of, include_extras=True).items():
28
+ if isinstance(hint, type):
29
+ fields.update(
30
+ {
31
+ f"{field}.{nested}": encrypted
32
+ for nested, encrypted in get_encrypted_fields(hint).items()
33
+ }
34
+ )
35
+ elif get_origin(hint) is Annotated:
36
+ _, *metadata = get_args(hint)
37
+ encrypted = next((m for m in metadata if isinstance(m, Encrypted)), None)
38
+ if encrypted:
39
+ fields[field] = encrypted
40
+ return fields
41
+
42
+
43
+ def get_data_subject_filed(of: type[BaseModel]) -> str | None:
44
+ return next(
45
+ (
46
+ field_name
47
+ for field_name, field_info in of.model_fields.items()
48
+ if DataSubject in field_info.metadata
49
+ ),
50
+ None,
51
+ )
52
+
53
+
54
+ class EventRegistry:
55
+ """Keeps mappings between event types and their names.
56
+
57
+ Normally, there is no need to use it directly. If one needs to have multiple
58
+ registries or wants more granular control, they can pass an instance
59
+ of EventRegistry to BackendFactory."""
60
+
61
+ def __init__(self) -> None:
62
+ self._types_to_names: dict[type[TEvent], str] = {}
63
+ self._names_to_types: dict[str, type[TEvent]] = {}
64
+ self._encrypted_fields: dict[type[TEvent], dict[str, Encrypted]] = {}
65
+ self._subject_fields: dict[type[TEvent], str] = {}
66
+ self._register_defined_events()
67
+
68
+ def _register_defined_events(self) -> None:
69
+ for event_type in Event.__subclasses__():
70
+ event_type not in self._types_to_names and self.add(event_type)
71
+
72
+ def add(self, event: type[TEvent]) -> type[TEvent]:
73
+ """Add event subclass to the registry."""
74
+ if event in self._types_to_names:
75
+ raise DuplicatedEvent(f"Duplicated Event detected! {event}")
76
+
77
+ name = event_name(event)
78
+ if name in self._names_to_types:
79
+ raise DuplicatedEvent(f"Duplicated Event name detected! {name}")
80
+
81
+ self._types_to_names[event] = name
82
+ self._names_to_types[name] = event
83
+ self._encrypted_fields[event] = get_encrypted_fields(of=event)
84
+ subject_field = get_data_subject_filed(of=event)
85
+ if subject_field is not None:
86
+ self._subject_fields[event] = subject_field
87
+ return event # for use as a decorator
88
+
89
+ def type_for_name(self, name: str) -> type[TEvent]:
90
+ if name not in self._names_to_types:
91
+ self._register_defined_events()
92
+ return self._names_to_types[name]
93
+
94
+ def name_for_type(self, event: type[TEvent]) -> str:
95
+ if event not in self._types_to_names:
96
+ self._register_defined_events()
97
+ return self._types_to_names[event]
98
+
99
+ def encrypted_fields(self, of: type[TEvent]) -> dict[str, Encrypted]:
100
+ return self._encrypted_fields[of]
101
+
102
+ def subject_filed(self, for_field: str, of: type[TEvent]) -> str | None:
103
+ metadata = self._encrypted_fields[of].get(for_field)
104
+ return (metadata and metadata.subject_field) or self._subject_fields.get(of)
@@ -1,23 +1,31 @@
1
1
  import dataclasses
2
2
  from collections.abc import Mapping, Sequence
3
- from dataclasses import dataclass
4
3
  from typing import cast
5
4
 
6
- from event_sourcery.event_store.event.dto import (
5
+ from event_sourcery.event_store.event._dto import (
7
6
  Context,
8
- Event,
9
7
  RawEvent,
10
8
  Recorded,
11
9
  RecordedRaw,
12
10
  WrappedEvent,
13
11
  )
14
- from event_sourcery.event_store.event.registry import EventRegistry
12
+ from event_sourcery.event_store.event._encryption import Encryption
13
+ from event_sourcery.event_store.event._registry import EventRegistry
15
14
  from event_sourcery.event_store.stream_id import StreamId
15
+ from event_sourcery.event_store.tenant_id import TenantId
16
16
 
17
17
 
18
- @dataclass(repr=False, frozen=True)
19
18
  class Serde:
20
19
  registry: EventRegistry
20
+ encryption: Encryption
21
+
22
+ def __init__(
23
+ self,
24
+ registry: EventRegistry,
25
+ encryption: Encryption | None = None,
26
+ ) -> None:
27
+ self.registry = registry
28
+ self.encryption = encryption or Encryption(registry)
21
29
 
22
30
  def deserialize(self, event: RawEvent) -> WrappedEvent:
23
31
  event_as_dict = dataclasses.asdict(event)
@@ -27,9 +35,16 @@ class Serde:
27
35
  context = event_as_dict.pop("context", {})
28
36
  event_as_dict["context"] = Context(**context)
29
37
  event_type = self.registry.type_for_name(event.name)
38
+
39
+ processed_data = self.encryption.decrypt(
40
+ event_type,
41
+ dict(data),
42
+ event.stream_id,
43
+ )
44
+
30
45
  return WrappedEvent[event_type]( # type: ignore[valid-type]
31
46
  **event_as_dict,
32
- event=event_type(**data),
47
+ event=event_type(**processed_data),
33
48
  )
34
49
 
35
50
  def deserialize_many(self, events: Sequence[RawEvent]) -> list[WrappedEvent]:
@@ -48,14 +63,13 @@ class Serde:
48
63
  event: WrappedEvent,
49
64
  stream_id: StreamId,
50
65
  ) -> RawEvent:
51
- model = cast(Event, event.event)
52
66
  return RawEvent(
53
67
  uuid=event.uuid,
54
68
  stream_id=stream_id,
55
69
  created_at=event.created_at,
56
70
  version=event.version,
57
71
  name=self.registry.name_for_type(type(event.event)),
58
- data=model.model_dump(mode="json"),
72
+ data=self.encryption.encrypt(event.event, stream_id),
59
73
  context=event.context.model_dump(mode="json"),
60
74
  )
61
75
 
@@ -63,3 +77,6 @@ class Serde:
63
77
  self, events: Sequence[WrappedEvent], stream_id: StreamId
64
78
  ) -> list[RawEvent]:
65
79
  return [self.serialize(event, stream_id) for event in events]
80
+
81
+ def scoped_for_tenant(self, tenant_id: TenantId) -> "Serde":
82
+ return Serde(self.registry, self.encryption.scoped_for_tenant(tenant_id))
@@ -206,5 +206,5 @@ class EventStore:
206
206
  """
207
207
  return EventStore(
208
208
  storage_strategy=self._storage_strategy.scoped_for_tenant(tenant_id),
209
- serde=self._serde,
209
+ serde=self._serde.scoped_for_tenant(tenant_id),
210
210
  )
@@ -47,3 +47,17 @@ class ClassModuleUnavailable(Exception):
47
47
 
48
48
  class DuplicatedEvent(Exception):
49
49
  pass
50
+
51
+
52
+ class PrivacyError(EventStoreException):
53
+ pass
54
+
55
+
56
+ @dataclass
57
+ class KeyNotFoundError(PrivacyError):
58
+ subject_id: str
59
+
60
+
61
+ @dataclass
62
+ class NoSubjectIdFound(PrivacyError):
63
+ stream_id: UUID
@@ -9,6 +9,8 @@ from event_sourcery.event_store.dispatcher import Dispatcher
9
9
  from event_sourcery.event_store.event import EventRegistry, RawEvent, RecordedRaw, Serde
10
10
  from event_sourcery.event_store.event_store import EventStore
11
11
  from event_sourcery.event_store.interfaces import (
12
+ EncryptionKeyStorageStrategy,
13
+ EncryptionStrategy,
12
14
  OutboxFiltererStrategy,
13
15
  OutboxStorageStrategy,
14
16
  )
@@ -55,3 +57,11 @@ class BackendFactory(abc.ABC):
55
57
  @abc.abstractmethod
56
58
  def without_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
57
59
  pass
60
+
61
+ @abc.abstractmethod
62
+ def with_encryption(
63
+ self,
64
+ strategy: EncryptionStrategy,
65
+ key_storage: EncryptionKeyStorageStrategy,
66
+ ) -> Self:
67
+ pass
@@ -2,7 +2,7 @@ import time
2
2
  from collections.abc import Generator, Iterator
3
3
  from contextlib import AbstractContextManager, contextmanager
4
4
  from copy import copy
5
- from dataclasses import dataclass, field
5
+ from dataclasses import dataclass, field, replace
6
6
  from datetime import timedelta
7
7
  from operator import getitem
8
8
 
@@ -11,12 +11,17 @@ from typing_extensions import Self
11
11
 
12
12
  from event_sourcery.event_store import (
13
13
  Dispatcher,
14
- Event,
15
14
  EventRegistry,
16
15
  EventStore,
17
16
  subscription,
18
17
  )
19
- from event_sourcery.event_store.event import Position, RawEvent, RecordedRaw, Serde
18
+ from event_sourcery.event_store.event import (
19
+ Encryption,
20
+ Position,
21
+ RawEvent,
22
+ RecordedRaw,
23
+ Serde,
24
+ )
20
25
  from event_sourcery.event_store.exceptions import ConcurrentStreamWriteError
21
26
  from event_sourcery.event_store.factory import (
22
27
  BackendFactory,
@@ -25,6 +30,8 @@ from event_sourcery.event_store.factory import (
25
30
  no_filter,
26
31
  )
27
32
  from event_sourcery.event_store.interfaces import (
33
+ EncryptionKeyStorageStrategy,
34
+ EncryptionStrategy,
28
35
  OutboxFiltererStrategy,
29
36
  OutboxStorageStrategy,
30
37
  StorageStrategy,
@@ -100,7 +107,7 @@ class InMemorySubscription(Iterator[list[RecordedRaw]]):
100
107
  record = self._pop_record()
101
108
  if record is not None:
102
109
  batch.append(record)
103
- time.sleep(0.1)
110
+ time.sleep(0.01)
104
111
  if time.monotonic() - start > self._timelimit.total_seconds():
105
112
  break
106
113
 
@@ -311,7 +318,7 @@ class Config(BaseModel):
311
318
  class InMemoryBackendFactory(BackendFactory):
312
319
  """Lightweight in-memory backend factory for testing and development."""
313
320
 
314
- serde = Serde(Event.__registry__)
321
+ serde = Serde(EventRegistry())
315
322
 
316
323
  _config: Config = field(default_factory=Config)
317
324
  _storage: Storage = field(default_factory=Storage)
@@ -357,3 +364,37 @@ class InMemoryBackendFactory(BackendFactory):
357
364
  def without_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
358
365
  self._outbox_strategy = None
359
366
  return self
367
+
368
+ def with_encryption(
369
+ self,
370
+ strategy: EncryptionStrategy,
371
+ key_storage: EncryptionKeyStorageStrategy,
372
+ ) -> Self:
373
+ registry = self.serde.registry
374
+ self.serde = Serde(
375
+ registry,
376
+ encryption=Encryption(
377
+ registry=registry,
378
+ strategy=strategy,
379
+ key_storage=key_storage,
380
+ ),
381
+ )
382
+ return self
383
+
384
+
385
+ @dataclass
386
+ class InMemoryKeyStorage(EncryptionKeyStorageStrategy):
387
+ _keys: dict[tuple[TenantId, str], bytes] = field(default_factory=dict)
388
+ _tenant_id: TenantId = DEFAULT_TENANT
389
+
390
+ def get(self, subject_id: str) -> bytes | None:
391
+ return self._keys.get((self._tenant_id, subject_id))
392
+
393
+ def store(self, subject_id: str, key: bytes) -> None:
394
+ self._keys[(self._tenant_id, subject_id)] = key
395
+
396
+ def delete(self, subject_id: str) -> None:
397
+ self._keys.pop((self._tenant_id, subject_id), None)
398
+
399
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
400
+ return replace(self, _tenant_id=tenant_id)
@@ -2,12 +2,13 @@ import abc
2
2
  from collections.abc import Iterator
3
3
  from contextlib import AbstractContextManager
4
4
  from datetime import timedelta
5
- from typing import Protocol
5
+ from typing import Any, Protocol
6
6
 
7
7
  from typing_extensions import Self
8
8
 
9
9
  from event_sourcery.event_store.event import Position, RawEvent, RecordedRaw
10
10
  from event_sourcery.event_store.stream_id import StreamId
11
+ from event_sourcery.event_store.tenant_id import TenantId
11
12
  from event_sourcery.event_store.versioning import Versioning
12
13
 
13
14
 
@@ -86,3 +87,31 @@ class StorageStrategy(abc.ABC):
86
87
  @abc.abstractmethod
87
88
  def scoped_for_tenant(self, tenant_id: str) -> Self:
88
89
  pass
90
+
91
+
92
+ class EncryptionKeyStorageStrategy(abc.ABC):
93
+ @abc.abstractmethod
94
+ def get(self, subject_id: str) -> bytes | None:
95
+ pass
96
+
97
+ @abc.abstractmethod
98
+ def store(self, subject_id: str, key: bytes) -> None:
99
+ pass
100
+
101
+ @abc.abstractmethod
102
+ def delete(self, subject_id: str) -> None:
103
+ pass
104
+
105
+ @abc.abstractmethod
106
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
107
+ pass
108
+
109
+
110
+ class EncryptionStrategy(abc.ABC):
111
+ @abc.abstractmethod
112
+ def encrypt(self, data: Any, key: bytes) -> str:
113
+ pass
114
+
115
+ @abc.abstractmethod
116
+ def decrypt(self, data: str, key: bytes) -> Any:
117
+ pass