eventsourcing 9.5.0b3__py3-none-any.whl
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.
- eventsourcing/__init__.py +0 -0
- eventsourcing/application.py +998 -0
- eventsourcing/cipher.py +107 -0
- eventsourcing/compressor.py +15 -0
- eventsourcing/cryptography.py +91 -0
- eventsourcing/dcb/__init__.py +0 -0
- eventsourcing/dcb/api.py +144 -0
- eventsourcing/dcb/application.py +159 -0
- eventsourcing/dcb/domain.py +369 -0
- eventsourcing/dcb/msgpack.py +38 -0
- eventsourcing/dcb/persistence.py +193 -0
- eventsourcing/dcb/popo.py +178 -0
- eventsourcing/dcb/postgres_tt.py +704 -0
- eventsourcing/dcb/tests.py +608 -0
- eventsourcing/dispatch.py +80 -0
- eventsourcing/domain.py +1964 -0
- eventsourcing/interface.py +164 -0
- eventsourcing/persistence.py +1429 -0
- eventsourcing/popo.py +267 -0
- eventsourcing/postgres.py +1441 -0
- eventsourcing/projection.py +502 -0
- eventsourcing/py.typed +0 -0
- eventsourcing/sqlite.py +816 -0
- eventsourcing/system.py +1203 -0
- eventsourcing/tests/__init__.py +3 -0
- eventsourcing/tests/application.py +483 -0
- eventsourcing/tests/domain.py +105 -0
- eventsourcing/tests/persistence.py +1744 -0
- eventsourcing/tests/postgres_utils.py +131 -0
- eventsourcing/utils.py +257 -0
- eventsourcing-9.5.0b3.dist-info/METADATA +253 -0
- eventsourcing-9.5.0b3.dist-info/RECORD +35 -0
- eventsourcing-9.5.0b3.dist-info/WHEEL +4 -0
- eventsourcing-9.5.0b3.dist-info/licenses/AUTHORS +10 -0
- eventsourcing-9.5.0b3.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import psycopg
|
|
4
|
+
from psycopg.sql import SQL, Identifier
|
|
5
|
+
|
|
6
|
+
from eventsourcing.dcb.postgres_tt import (
|
|
7
|
+
DB_FUNCTION_NAME_DCB_CONDITIONAL_APPEND_TT,
|
|
8
|
+
DB_FUNCTION_NAME_DCB_UNCONDITIONAL_APPEND_TT,
|
|
9
|
+
DB_TYPE_NAME_DCB_EVENT_TT,
|
|
10
|
+
DB_TYPE_NAME_DCB_QUERY_ITEM_TT,
|
|
11
|
+
)
|
|
12
|
+
from eventsourcing.postgres import PostgresDatastore
|
|
13
|
+
from examples.dcb_enrolment_with_basic_objects.postgres_ts import (
|
|
14
|
+
PG_FUNCTION_NAME_DCB_CHECK_APPEND_CONDITION_TS,
|
|
15
|
+
PG_FUNCTION_NAME_DCB_INSERT_EVENTS_TS,
|
|
16
|
+
PG_FUNCTION_NAME_DCB_SELECT_EVENTS_TS,
|
|
17
|
+
PG_PROCEDURE_NAME_DCB_APPEND_EVENTS_TS,
|
|
18
|
+
PG_TYPE_NAME_DCB_EVENT_TS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pg_close_all_connections(
|
|
23
|
+
name: str = "eventsourcing",
|
|
24
|
+
host: str = "127.0.0.1",
|
|
25
|
+
port: str = "5432",
|
|
26
|
+
user: str = "postgres",
|
|
27
|
+
password: str = "postgres", # noqa: S107
|
|
28
|
+
) -> None:
|
|
29
|
+
try:
|
|
30
|
+
# For local development... probably.
|
|
31
|
+
pg_conn = psycopg.connect(
|
|
32
|
+
dbname=name,
|
|
33
|
+
host=host,
|
|
34
|
+
port=port,
|
|
35
|
+
)
|
|
36
|
+
except psycopg.Error:
|
|
37
|
+
# For GitHub actions.
|
|
38
|
+
"""CREATE ROLE postgres LOGIN SUPERUSER PASSWORD 'postgres';"""
|
|
39
|
+
pg_conn = psycopg.connect(
|
|
40
|
+
dbname=name,
|
|
41
|
+
host=host,
|
|
42
|
+
port=port,
|
|
43
|
+
user=user,
|
|
44
|
+
password=password,
|
|
45
|
+
)
|
|
46
|
+
close_all_connections = """
|
|
47
|
+
SELECT
|
|
48
|
+
pg_terminate_backend(pid)
|
|
49
|
+
FROM
|
|
50
|
+
pg_stat_activity
|
|
51
|
+
WHERE
|
|
52
|
+
-- don't kill my own connection!
|
|
53
|
+
pid <> pg_backend_pid();
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
pg_conn_cursor = pg_conn.cursor()
|
|
57
|
+
pg_conn_cursor.execute(close_all_connections)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def drop_tables() -> None:
|
|
61
|
+
|
|
62
|
+
for schema in ["public", "myschema"]:
|
|
63
|
+
datastore = PostgresDatastore(
|
|
64
|
+
dbname=os.environ.get("POSTGRES_DBNAME", "eventsourcing"),
|
|
65
|
+
host=os.environ.get("POSTGRES_HOST", "127.0.0.1"),
|
|
66
|
+
port=os.environ.get("POSTGRES_PORT", "5432"),
|
|
67
|
+
user=os.environ.get("POSTGRES_USER", "eventsourcing"),
|
|
68
|
+
password=os.environ.get("POSTGRES_PASSWORD", "eventsourcing"),
|
|
69
|
+
schema=schema,
|
|
70
|
+
)
|
|
71
|
+
with datastore.transaction(commit=True) as curs:
|
|
72
|
+
select_table_names = SQL(
|
|
73
|
+
"SELECT table_name FROM information_schema.tables "
|
|
74
|
+
"WHERE table_schema = %s"
|
|
75
|
+
)
|
|
76
|
+
fetchall = curs.execute(select_table_names, (datastore.schema,)).fetchall()
|
|
77
|
+
for row in fetchall:
|
|
78
|
+
table_name = row["table_name"]
|
|
79
|
+
# print(f"Dropping table '{table_name}' in schema '{schema}'")
|
|
80
|
+
statement = SQL("DROP TABLE IF EXISTS {0}.{1} CASCADE").format(
|
|
81
|
+
Identifier(datastore.schema), Identifier(table_name)
|
|
82
|
+
)
|
|
83
|
+
curs.execute(statement, prepare=False)
|
|
84
|
+
# print(f"Dropped table '{table_name}' in schema '{schema}'")
|
|
85
|
+
|
|
86
|
+
# Also drop composite types.
|
|
87
|
+
composite_types = [
|
|
88
|
+
"stored_event_uuid",
|
|
89
|
+
"stored_event_text",
|
|
90
|
+
PG_TYPE_NAME_DCB_EVENT_TS,
|
|
91
|
+
DB_TYPE_NAME_DCB_EVENT_TT,
|
|
92
|
+
DB_TYPE_NAME_DCB_QUERY_ITEM_TT,
|
|
93
|
+
]
|
|
94
|
+
for name in composite_types:
|
|
95
|
+
statement = SQL("DROP TYPE IF EXISTS {schema}.{name} CASCADE").format(
|
|
96
|
+
schema=Identifier(datastore.schema),
|
|
97
|
+
name=Identifier(name),
|
|
98
|
+
)
|
|
99
|
+
curs.execute(statement, prepare=False)
|
|
100
|
+
|
|
101
|
+
# Also drop functions.
|
|
102
|
+
functions = [
|
|
103
|
+
"es_insert_events_uuid",
|
|
104
|
+
"es_insert_events_text",
|
|
105
|
+
PG_FUNCTION_NAME_DCB_INSERT_EVENTS_TS,
|
|
106
|
+
PG_FUNCTION_NAME_DCB_SELECT_EVENTS_TS,
|
|
107
|
+
PG_FUNCTION_NAME_DCB_CHECK_APPEND_CONDITION_TS,
|
|
108
|
+
DB_FUNCTION_NAME_DCB_UNCONDITIONAL_APPEND_TT,
|
|
109
|
+
DB_FUNCTION_NAME_DCB_CONDITIONAL_APPEND_TT,
|
|
110
|
+
]
|
|
111
|
+
for name in functions:
|
|
112
|
+
statement = SQL(
|
|
113
|
+
"DROP FUNCTION IF EXISTS {schema}.{name} CASCADE"
|
|
114
|
+
).format(
|
|
115
|
+
schema=Identifier(datastore.schema),
|
|
116
|
+
name=Identifier(name),
|
|
117
|
+
)
|
|
118
|
+
curs.execute(statement, prepare=False)
|
|
119
|
+
|
|
120
|
+
# Also drop procedures.
|
|
121
|
+
procedures = [
|
|
122
|
+
PG_PROCEDURE_NAME_DCB_APPEND_EVENTS_TS,
|
|
123
|
+
]
|
|
124
|
+
for name in procedures:
|
|
125
|
+
statement = SQL(
|
|
126
|
+
"DROP PROCEDURE IF EXISTS {schema}.{name} CASCADE"
|
|
127
|
+
).format(
|
|
128
|
+
schema=Identifier(datastore.schema),
|
|
129
|
+
name=Identifier(name),
|
|
130
|
+
)
|
|
131
|
+
curs.execute(statement, prepare=False)
|
eventsourcing/utils.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from inspect import isfunction
|
|
7
|
+
from random import random
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from time import sleep
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import TYPE_CHECKING, Any, TypeVar, no_type_check, overload
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from types import FunctionType, WrapperDescriptorType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TopicError(Exception):
|
|
18
|
+
"""Raised when topic doesn't resolve."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SupportsTopic = type | Callable[..., Any] | ModuleType
|
|
22
|
+
|
|
23
|
+
_type_cache: dict[SupportsTopic, str] = {}
|
|
24
|
+
_topic_cache: dict[str, SupportsTopic] = {}
|
|
25
|
+
_topic_cache_lock = Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_topic(obj: SupportsTopic, /) -> str:
|
|
29
|
+
"""Returns a "topic string" that locates the given class
|
|
30
|
+
in its module. The string is formed by joining the
|
|
31
|
+
module name and the class qualname separated by the
|
|
32
|
+
colon character.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
return _type_cache[obj]
|
|
36
|
+
except KeyError:
|
|
37
|
+
topic = construct_topic(obj)
|
|
38
|
+
register_topic(topic, obj)
|
|
39
|
+
_type_cache[obj] = topic
|
|
40
|
+
return topic
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def construct_topic(obj: SupportsTopic, /) -> str:
|
|
44
|
+
return getattr(obj, "TOPIC", f"{obj.__module__}:{obj.__qualname__}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_topic(topic: str) -> Any:
|
|
48
|
+
"""Returns an object located by the given topic.
|
|
49
|
+
|
|
50
|
+
This function can be (is) used to locate domain
|
|
51
|
+
event classes and aggregate classes from the
|
|
52
|
+
topics in stored events and snapshots. It can
|
|
53
|
+
also be used to locate compression modules,
|
|
54
|
+
timezone objects, etc.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
obj = _topic_cache[topic]
|
|
58
|
+
except KeyError:
|
|
59
|
+
module_name, _, attr_name = topic.partition(":")
|
|
60
|
+
|
|
61
|
+
attr_name_parts = attr_name.split(".")
|
|
62
|
+
for i in range(len(attr_name_parts) - 1, 0, -1):
|
|
63
|
+
part_name = ".".join(attr_name_parts[:i])
|
|
64
|
+
try:
|
|
65
|
+
obj = _topic_cache[f"{module_name}:{part_name}"]
|
|
66
|
+
except KeyError:
|
|
67
|
+
continue
|
|
68
|
+
else:
|
|
69
|
+
attr_name = ".".join(attr_name_parts[i:])
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
obj = _topic_cache[module_name]
|
|
75
|
+
except KeyError:
|
|
76
|
+
module_name_parts = module_name.split(".")
|
|
77
|
+
for i in range(len(module_name_parts) - 1, 0, -1):
|
|
78
|
+
part_name = ".".join(module_name_parts[:i])
|
|
79
|
+
try:
|
|
80
|
+
obj = _topic_cache[f"{part_name}"]
|
|
81
|
+
except KeyError:
|
|
82
|
+
continue
|
|
83
|
+
else:
|
|
84
|
+
module_name = ".".join([obj.__name__, *module_name_parts[i:]])
|
|
85
|
+
break
|
|
86
|
+
try:
|
|
87
|
+
obj = importlib.import_module(module_name)
|
|
88
|
+
except ImportError as e:
|
|
89
|
+
msg = f"Failed to resolve topic '{topic}': {e}"
|
|
90
|
+
raise TopicError(msg) from e
|
|
91
|
+
if attr_name:
|
|
92
|
+
try:
|
|
93
|
+
for attr_name_part in attr_name.split("."):
|
|
94
|
+
obj = getattr(obj, attr_name_part)
|
|
95
|
+
except AttributeError as e:
|
|
96
|
+
msg = f"Failed to resolve topic '{topic}': {e}"
|
|
97
|
+
raise TopicError(msg) from e
|
|
98
|
+
register_topic(topic, obj)
|
|
99
|
+
return obj
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register_topic(topic: str, obj: SupportsTopic) -> None:
|
|
103
|
+
"""Registers a topic with an object, so the object will be
|
|
104
|
+
returned whenever the topic is resolved.
|
|
105
|
+
|
|
106
|
+
This function can be used to cache the topic of a class, so
|
|
107
|
+
that the topic can be resolved faster. It can also be used to
|
|
108
|
+
register old topics for objects that have been renamed or moved,
|
|
109
|
+
so that old topics will resolve to the renamed or moved object.
|
|
110
|
+
"""
|
|
111
|
+
with _topic_cache_lock:
|
|
112
|
+
try:
|
|
113
|
+
cached_obj = _topic_cache[topic]
|
|
114
|
+
except KeyError:
|
|
115
|
+
_topic_cache[topic] = obj
|
|
116
|
+
else:
|
|
117
|
+
if cached_obj != obj:
|
|
118
|
+
msg = (
|
|
119
|
+
f"Refusing to cache {obj} (oid {id(obj)}): {cached_obj} (oid "
|
|
120
|
+
f"{id(cached_obj)}) is already registered for topic '{topic}'"
|
|
121
|
+
)
|
|
122
|
+
raise TopicError(msg)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def clear_topic_cache() -> None:
|
|
126
|
+
_topic_cache.clear()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def retry(
|
|
130
|
+
exc: type[Exception] | tuple[type[Exception], ...] = Exception,
|
|
131
|
+
max_attempts: int = 1,
|
|
132
|
+
wait: float = 0,
|
|
133
|
+
stall: float = 0,
|
|
134
|
+
) -> Callable[[Any], Any]:
|
|
135
|
+
"""Retry decorator.
|
|
136
|
+
|
|
137
|
+
:param exc: List of exceptions that will cause the call to be retried if raised.
|
|
138
|
+
:param max_attempts: Maximum number of attempts to try.
|
|
139
|
+
:param wait: Amount of time to wait before retrying after an exception.
|
|
140
|
+
:param stall: Amount of time to wait before the first attempt.
|
|
141
|
+
:return: Returns the value returned by decorated function.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@no_type_check
|
|
145
|
+
def _retry(func: Callable) -> Callable:
|
|
146
|
+
@wraps(func)
|
|
147
|
+
def retry_decorator(*args: Any, **kwargs: Any) -> Any:
|
|
148
|
+
if stall:
|
|
149
|
+
sleep(stall)
|
|
150
|
+
attempts = 0
|
|
151
|
+
while True:
|
|
152
|
+
try:
|
|
153
|
+
return func(*args, **kwargs)
|
|
154
|
+
except exc: # noqa: PERF203
|
|
155
|
+
attempts += 1
|
|
156
|
+
if max_attempts is None or attempts < max_attempts:
|
|
157
|
+
sleep(wait * (1 + 0.1 * (random() - 0.5))) # noqa: S311
|
|
158
|
+
else:
|
|
159
|
+
# Max retries exceeded.
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
return retry_decorator
|
|
163
|
+
|
|
164
|
+
# If using decorator in bare form, the decorated
|
|
165
|
+
# function is the first arg, so check 'exc'.
|
|
166
|
+
if isfunction(exc):
|
|
167
|
+
# Remember the given function.
|
|
168
|
+
_func = exc
|
|
169
|
+
# Set 'exc' to a sensible exception class for _retry().
|
|
170
|
+
exc = Exception
|
|
171
|
+
# Wrap and return.
|
|
172
|
+
return _retry(func=_func)
|
|
173
|
+
# Check decorator args, and return _retry,
|
|
174
|
+
# to be called with the decorated function.
|
|
175
|
+
if isinstance(exc, (list, tuple)):
|
|
176
|
+
for _exc in exc:
|
|
177
|
+
if not (isinstance(_exc, type) and issubclass(_exc, Exception)):
|
|
178
|
+
msg = f"not an exception class: {_exc}"
|
|
179
|
+
raise TypeError(msg)
|
|
180
|
+
elif not (isinstance(exc, type) and issubclass(exc, Exception)):
|
|
181
|
+
msg = f"not an exception class: {exc}"
|
|
182
|
+
raise TypeError(msg)
|
|
183
|
+
if not isinstance(max_attempts, int):
|
|
184
|
+
msg = f"'max_attempts' must be an int: {max_attempts}"
|
|
185
|
+
raise TypeError(msg)
|
|
186
|
+
if not isinstance(wait, (float, int)):
|
|
187
|
+
msg = f"'wait' must be a float: {max_attempts}"
|
|
188
|
+
raise TypeError(msg)
|
|
189
|
+
if not isinstance(stall, (float, int)):
|
|
190
|
+
msg = f"'stall' must be a float: {max_attempts}"
|
|
191
|
+
raise TypeError(msg)
|
|
192
|
+
return _retry
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def strtobool(val: str) -> bool:
|
|
196
|
+
"""Convert a string representation of truth to True or False.
|
|
197
|
+
|
|
198
|
+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
|
199
|
+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
|
200
|
+
'val' is anything else.
|
|
201
|
+
"""
|
|
202
|
+
if not isinstance(val, str):
|
|
203
|
+
msg = f"{val} is not a str"
|
|
204
|
+
raise TypeError(msg)
|
|
205
|
+
val = val.lower()
|
|
206
|
+
if val in ("y", "yes", "t", "true", "on", "1"):
|
|
207
|
+
return True
|
|
208
|
+
if val in ("n", "no", "f", "false", "off", "0"):
|
|
209
|
+
return False
|
|
210
|
+
msg = f"invalid truth value {val!r}"
|
|
211
|
+
raise ValueError(msg)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def reversed_keys(d: dict[Any, Any]) -> Iterator[Any]:
|
|
215
|
+
return reversed(d.keys())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# TODO: Inline this now.
|
|
219
|
+
def get_method_name(
|
|
220
|
+
method: Callable[..., Any] | FunctionType | WrapperDescriptorType,
|
|
221
|
+
) -> str:
|
|
222
|
+
return method.__qualname__
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
EnvType = Mapping[str, str]
|
|
226
|
+
T = TypeVar("T")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class Environment(dict[str, str]):
|
|
230
|
+
def __init__(self, name: str = "", env: EnvType | None = None):
|
|
231
|
+
super().__init__(env or {})
|
|
232
|
+
self.name = name
|
|
233
|
+
|
|
234
|
+
@overload # type: ignore[override]
|
|
235
|
+
def get(self, __key: str, /) -> str | None: ... # pragma: no cover
|
|
236
|
+
|
|
237
|
+
@overload
|
|
238
|
+
def get(self, __key: str, /, __default: str) -> str: ... # pragma: no cover
|
|
239
|
+
|
|
240
|
+
@overload
|
|
241
|
+
def get(self, __key: str, /, __default: T) -> str | T: ... # pragma: no cover
|
|
242
|
+
|
|
243
|
+
def get( # pyright: ignore [reportIncompatibleMethodOverride]
|
|
244
|
+
self, __key: str, /, __default: str | T | None = None
|
|
245
|
+
) -> str | T | None:
|
|
246
|
+
for _key in self.create_keys(__key):
|
|
247
|
+
value = super().get(_key, None)
|
|
248
|
+
if value is not None:
|
|
249
|
+
return value
|
|
250
|
+
return __default
|
|
251
|
+
|
|
252
|
+
def create_keys(self, key: str) -> list[str]:
|
|
253
|
+
keys = []
|
|
254
|
+
if self.name:
|
|
255
|
+
keys.append(self.name.upper() + "_" + key)
|
|
256
|
+
keys.append(key)
|
|
257
|
+
return keys
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eventsourcing
|
|
3
|
+
Version: 9.5.0b3
|
|
4
|
+
Summary: Event sourcing in Python
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
License-File: AUTHORS
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: event sourcing,event store,domain driven design,domain-driven design,ddd,cqrs,cqs
|
|
9
|
+
Author: John Bywater
|
|
10
|
+
Author-email: john.bywater@appropriatesoftware.net
|
|
11
|
+
Requires-Python: >=3.10.0
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Provides-Extra: crypto
|
|
27
|
+
Provides-Extra: cryptography
|
|
28
|
+
Provides-Extra: postgres
|
|
29
|
+
Requires-Dist: cryptography (>=44.0) ; extra == "cryptography"
|
|
30
|
+
Requires-Dist: psycopg[pool] (>=3.2) ; extra == "postgres"
|
|
31
|
+
Requires-Dist: pycryptodome (>=3.22) ; extra == "crypto"
|
|
32
|
+
Requires-Dist: typing_extensions
|
|
33
|
+
Project-URL: Documentation, https://eventsourcing.readthedocs.io/
|
|
34
|
+
Project-URL: Homepage, https://github.com/pyeventsourcing/eventsourcing
|
|
35
|
+
Project-URL: Repository, https://github.com/pyeventsourcing/eventsourcing
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
[](https://github.com/pyeventsourcing/eventsourcing)
|
|
39
|
+
[](https://coveralls.io/github/pyeventsourcing/eventsourcing?branch=main)
|
|
40
|
+
[](https://eventsourcing.readthedocs.io/en/stable/)
|
|
41
|
+
[](https://pypi.org/project/eventsourcing/)
|
|
42
|
+
[](https://pypistats.org/packages/eventsourcing)
|
|
43
|
+
[](https://github.com/psf/black)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Event Sourcing in Python
|
|
47
|
+
|
|
48
|
+
This project is a comprehensive Python library for implementing event sourcing, a design pattern where all
|
|
49
|
+
changes to application state are stored as a sequence of events. This library provides a solid foundation
|
|
50
|
+
for building event-sourced applications in Python, with a focus on reliability, performance, and developer
|
|
51
|
+
experience. Please [read the docs](https://eventsourcing.readthedocs.io/). See also [extension projects](https://github.com/pyeventsourcing).
|
|
52
|
+
|
|
53
|
+
*"totally amazing and a pleasure to use"*
|
|
54
|
+
|
|
55
|
+
*"very clean and intuitive"*
|
|
56
|
+
|
|
57
|
+
*"a huge help and time saver"*
|
|
58
|
+
|
|
59
|
+
[](https://deepwiki.com/pyeventsourcing/eventsourcing)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
Use pip to install the [stable distribution](https://pypi.org/project/eventsourcing/)
|
|
65
|
+
from the Python Package Index.
|
|
66
|
+
|
|
67
|
+
$ pip install eventsourcing
|
|
68
|
+
|
|
69
|
+
Please note, it is recommended to install Python
|
|
70
|
+
packages into a Python virtual environment.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Synopsis
|
|
74
|
+
|
|
75
|
+
Define aggregates with the `Aggregate` class and the `@event` decorator.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from eventsourcing.domain import Aggregate, event
|
|
79
|
+
|
|
80
|
+
class Dog(Aggregate):
|
|
81
|
+
@event('Registered')
|
|
82
|
+
def __init__(self, name: str) -> None:
|
|
83
|
+
self.name = name
|
|
84
|
+
self.tricks: list[str] = []
|
|
85
|
+
|
|
86
|
+
@event('TrickAdded')
|
|
87
|
+
def add_trick(self, trick: str) -> None:
|
|
88
|
+
self.tricks.append(trick)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Define application objects with the `Application` class.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from typing import Any
|
|
95
|
+
from uuid import UUID
|
|
96
|
+
|
|
97
|
+
from eventsourcing.application import Application
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DogSchool(Application[UUID]):
|
|
101
|
+
def register_dog(self, name: str) -> UUID:
|
|
102
|
+
dog = Dog(name)
|
|
103
|
+
self.save(dog)
|
|
104
|
+
return dog.id
|
|
105
|
+
|
|
106
|
+
def add_trick(self, dog_id: UUID, trick: str) -> None:
|
|
107
|
+
dog: Dog = self.repository.get(dog_id)
|
|
108
|
+
dog.add_trick(trick)
|
|
109
|
+
self.save(dog)
|
|
110
|
+
|
|
111
|
+
def get_dog(self, dog_id: UUID) -> dict[str, Any]:
|
|
112
|
+
dog: Dog = self.repository.get(dog_id)
|
|
113
|
+
return {'name': dog.name, 'tricks': tuple(dog.tricks)}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Write a test.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
def test_dog_school() -> None:
|
|
120
|
+
# Construct application object.
|
|
121
|
+
school = DogSchool()
|
|
122
|
+
|
|
123
|
+
# Evolve application state.
|
|
124
|
+
dog_id = school.register_dog('Fido')
|
|
125
|
+
school.add_trick(dog_id, 'roll over')
|
|
126
|
+
school.add_trick(dog_id, 'play dead')
|
|
127
|
+
|
|
128
|
+
# Query application state.
|
|
129
|
+
dog = school.get_dog(dog_id)
|
|
130
|
+
assert dog['name'] == 'Fido'
|
|
131
|
+
assert dog['tricks'] == ('roll over', 'play dead')
|
|
132
|
+
|
|
133
|
+
# Select notifications.
|
|
134
|
+
notifications = school.notification_log.select(start=1, limit=10)
|
|
135
|
+
assert len(notifications) == 3
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Run the test with the default persistence module. Events are stored
|
|
139
|
+
in memory using Python objects.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
test_dog_school()
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Configure the application to run with an SQLite database. Other persistence modules are available.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import os
|
|
149
|
+
|
|
150
|
+
os.environ["PERSISTENCE_MODULE"] = 'eventsourcing.sqlite'
|
|
151
|
+
os.environ["SQLITE_DBNAME"] = ':memory:'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Run the test with SQLite.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
test_dog_school()
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See the [documentation](https://eventsourcing.readthedocs.io/) for more information.
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
## Features
|
|
164
|
+
|
|
165
|
+
**Flexible event store** — flexible persistence of domain events. Combines
|
|
166
|
+
an event mapper and an event recorder in ways that can be easily extended.
|
|
167
|
+
Mapper uses a transcoder that can be easily substituted or extended to support
|
|
168
|
+
custom model object types. Recorders supporting different databases can be easily
|
|
169
|
+
substituted and configured with environment variables.
|
|
170
|
+
|
|
171
|
+
**Domain models and applications** — base classes for event-sourced domain models
|
|
172
|
+
and applications. Suggests how to structure an event-sourced application. This
|
|
173
|
+
library supports event-sourced aggregates and dynamic consistency boundaries.
|
|
174
|
+
|
|
175
|
+
**Application-level encryption and compression** — encrypts and decrypts events inside the
|
|
176
|
+
application. This means data will be encrypted in transit across a network ("on the wire")
|
|
177
|
+
and at disk level including backups ("at rest"), which is a legal requirement in some
|
|
178
|
+
jurisdictions when dealing with personally identifiable information (PII) for example
|
|
179
|
+
the EU's GDPR. Compression reduces the size of stored domain events and snapshots, usually
|
|
180
|
+
by around 25% to 50% of the original size. Compression reduces the size of data
|
|
181
|
+
in the database and decreases transit time across a network.
|
|
182
|
+
|
|
183
|
+
**Snapshotting** — reduces access-time for aggregates with many domain events.
|
|
184
|
+
|
|
185
|
+
**Versioning** - allows domain model changes to be introduced after an application
|
|
186
|
+
has been deployed. Both domain events and aggregate classes can be versioned.
|
|
187
|
+
The recorded state of an older version can be upcast to be compatible with a new
|
|
188
|
+
version. Stored events and snapshots are upcast from older versions
|
|
189
|
+
to new versions before the event or aggregate object is reconstructed.
|
|
190
|
+
|
|
191
|
+
**Optimistic concurrency control** — ensures a distributed or horizontally scaled
|
|
192
|
+
application doesn't become inconsistent due to concurrent method execution. Leverages
|
|
193
|
+
optimistic concurrency controls in adapted database management systems.
|
|
194
|
+
|
|
195
|
+
**Notifications and projections** — reliable propagation of application
|
|
196
|
+
events with pull-based notifications allows the application state to be
|
|
197
|
+
projected accurately into replicas, indexes, view models, and other applications.
|
|
198
|
+
Supports materialised views and CQRS.
|
|
199
|
+
|
|
200
|
+
**Event-driven systems** — reliable event processing. Event-driven systems
|
|
201
|
+
can be defined independently of particular persistence infrastructure and mode of
|
|
202
|
+
running.
|
|
203
|
+
|
|
204
|
+
**Detailed documentation** — documentation provides general overview, introduction
|
|
205
|
+
of concepts, explanation of usage, and detailed descriptions of library classes.
|
|
206
|
+
All code is annotated with type hints.
|
|
207
|
+
|
|
208
|
+
**Worked examples** — includes examples showing how to develop aggregates, applications
|
|
209
|
+
and systems.
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
## Extensions
|
|
214
|
+
|
|
215
|
+
The GitHub organisation
|
|
216
|
+
[Event Sourcing in Python](https://github.com/pyeventsourcing)
|
|
217
|
+
hosts extension projects for the Python eventsourcing library.
|
|
218
|
+
There are projects that adapt popular ORMs such as
|
|
219
|
+
[Django](https://github.com/pyeventsourcing/eventsourcing-django#readme)
|
|
220
|
+
and [SQLAlchemy](https://github.com/pyeventsourcing/eventsourcing-sqlalchemy#readme).
|
|
221
|
+
There are projects that adapt specialist event stores such as
|
|
222
|
+
[Axon Server](https://github.com/pyeventsourcing/eventsourcing-axonserver#readme) and
|
|
223
|
+
[KurrentDB](https://github.com/pyeventsourcing/eventsourcing-kurrentdb#readme).
|
|
224
|
+
There are projects that support popular NoSQL databases such as
|
|
225
|
+
[DynamoDB](https://github.com/pyeventsourcing/eventsourcing-dynamodb#readme).
|
|
226
|
+
There are also projects that provide examples of using the
|
|
227
|
+
library with web frameworks such as
|
|
228
|
+
[FastAPI](https://github.com/pyeventsourcing/example-fastapi#readme)
|
|
229
|
+
and [Flask](https://github.com/pyeventsourcing/example-flask#readme),
|
|
230
|
+
and for serving applications and running systems with efficient
|
|
231
|
+
inter-process communication technologies like [gRPC](https://github.com/pyeventsourcing/eventsourcing-grpc#readme).
|
|
232
|
+
And there are examples of event-sourced applications and systems
|
|
233
|
+
of event-sourced applications, such as the
|
|
234
|
+
[Paxos system](https://github.com/pyeventsourcing/example-paxos#readme),
|
|
235
|
+
which is used as the basis for a
|
|
236
|
+
[replicated state machine](https://github.com/pyeventsourcing/example-paxos/tree/master/replicatedstatemachine),
|
|
237
|
+
which is used as the basis for a
|
|
238
|
+
[distributed key-value store](https://github.com/pyeventsourcing/example-paxos/tree/master/keyvaluestore).
|
|
239
|
+
|
|
240
|
+
## Project
|
|
241
|
+
|
|
242
|
+
This project is [hosted on GitHub](https://github.com/pyeventsourcing/eventsourcing).
|
|
243
|
+
|
|
244
|
+
Please register questions, requests and
|
|
245
|
+
[issues on GitHub](https://github.com/pyeventsourcing/eventsourcing/issues),
|
|
246
|
+
or post in the project's Slack channel.
|
|
247
|
+
|
|
248
|
+
There is a [Slack channel](https://join.slack.com/t/eventsourcinginpython/shared_invite/zt-3hogb36o-LCvKd4Rz8JMALoLSl_pQ8g)
|
|
249
|
+
for this project, which you are [welcome to join](https://join.slack.com/t/eventsourcinginpython/shared_invite/zt-3hogb36o-LCvKd4Rz8JMALoLSl_pQ8g).
|
|
250
|
+
|
|
251
|
+
Please refer to the [documentation](https://eventsourcing.readthedocs.io/) for installation and usage guides.
|
|
252
|
+
|
|
253
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
eventsourcing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
eventsourcing/application.py,sha256=OjzmDs0SbNRRUVs4Um2qmfiD6jD0QA-jZx66c7tixu4,37321
|
|
3
|
+
eventsourcing/cipher.py,sha256=ulTBtX5K9ejRAkdUaUbdIaj4H7anYwDOi7JxOolj2uo,3295
|
|
4
|
+
eventsourcing/compressor.py,sha256=qEYWvsUXFLyhKgfuv-HGNJ6VF4sRw4z0IxbNW9ukOfc,385
|
|
5
|
+
eventsourcing/cryptography.py,sha256=aFZLlJxxSb5seVbh94-T8FA_RIGOe-VFu5SJrbOnwUU,2969
|
|
6
|
+
eventsourcing/dcb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
eventsourcing/dcb/api.py,sha256=2h9KzUi1NWZjq7y2DHz_wRYUIv7NzibyJdsd2iTk5P8,4153
|
|
8
|
+
eventsourcing/dcb/application.py,sha256=-2CG1ph1nHRGbwrlXnNikz4898YVCmkXS8gi5yqIn4Y,5089
|
|
9
|
+
eventsourcing/dcb/domain.py,sha256=YrXAnDfQSrtraq8Je0QNM5rtnb8teon3iJFZJCJmZgA,12250
|
|
10
|
+
eventsourcing/dcb/msgpack.py,sha256=a6AZgou_TvlSSzUqIS5bYCSFxPyOrP24FJhevrib4C0,1097
|
|
11
|
+
eventsourcing/dcb/persistence.py,sha256=EScPVFi0xPtbxwykyeXOx1NA0KgUYOmel-z0BHhCvEg,6116
|
|
12
|
+
eventsourcing/dcb/popo.py,sha256=4mXl2m2gbs6uLjgLRGmBh0YzAP_FwDmZ11-NkLhXSjE,5678
|
|
13
|
+
eventsourcing/dcb/postgres_tt.py,sha256=pmAQKLydTFQTi6QIc_Frqko9SjoTCgZvs68WufKHB-Y,23036
|
|
14
|
+
eventsourcing/dcb/tests.py,sha256=QGBwWHWpzeeUHZHjzpBXZrJ0EBRX4ldRJ8oe7Llt_pc,24698
|
|
15
|
+
eventsourcing/dispatch.py,sha256=A36Oj0zs28lk2E08p2M2QMEkj4PUECAs60OeEQziTf4,2828
|
|
16
|
+
eventsourcing/domain.py,sha256=WuGpjXhDYH1l2yuJgZYQ1lmzrz7LAeraGW3VkkVy2iw,75999
|
|
17
|
+
eventsourcing/interface.py,sha256=K7tAJjriOJa_XB9-wptJR9VTb5sHlBpqrz3BGUXxI4A,5387
|
|
18
|
+
eventsourcing/persistence.py,sha256=PKLBVnXASLgOcRd5vKMsiOpnkmeHDmXHwPNSSLRQCcI,50057
|
|
19
|
+
eventsourcing/popo.py,sha256=fBPLn6_49kDbfqtFvVPozYfPQfeSl4VhBP38klCUL_A,9203
|
|
20
|
+
eventsourcing/postgres.py,sha256=7NjIrrP5g46zfbeP-zB4vyxFqcWgQ6pHaNPix8cUB74,56277
|
|
21
|
+
eventsourcing/projection.py,sha256=SFek7kw2op0Zg3bchZV9xSRQT-OnssT2TFN8TgmRSSU,18718
|
|
22
|
+
eventsourcing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
eventsourcing/sqlite.py,sha256=xvgu9euGYalgMoKurCuzBWySEMi3Ga4rGzXmm0XGep8,27949
|
|
24
|
+
eventsourcing/system.py,sha256=_jAYtB7gkDx3Kdm0we2AJJHQR5JLPYtA7loOrS7mDHY,45907
|
|
25
|
+
eventsourcing/tests/__init__.py,sha256=FtOyuj-L-oSisYeByTIrnUw-XzsctSbq76XmjPy5fMc,102
|
|
26
|
+
eventsourcing/tests/application.py,sha256=pE2tYfuykbV4Q6WW1U-gi_YgyW2485NGLXkemaH46Do,18072
|
|
27
|
+
eventsourcing/tests/domain.py,sha256=yN-F6gMRumeX6nIXIcZGxAR3RrUslzmEMM8JksnkI8Q,3227
|
|
28
|
+
eventsourcing/tests/persistence.py,sha256=jsS2JUo5U8gG5POdWGfuTDiQzhd8OoroWpt8QzpID1o,62942
|
|
29
|
+
eventsourcing/tests/postgres_utils.py,sha256=hKW7xd3yzOHl7W5Q4-1YAecoLRS7QaYr8zBOrUFWfVA,4690
|
|
30
|
+
eventsourcing/utils.py,sha256=tR4qVXHvgq0fJuS_1x_ULahdePrQPvg8wSEtmEVaRy0,8586
|
|
31
|
+
eventsourcing-9.5.0b3.dist-info/METADATA,sha256=AhS14onZ5jHiWf-l7DtdLdKNj0NnOlDXMvYQ4tnLbGM,10564
|
|
32
|
+
eventsourcing-9.5.0b3.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
33
|
+
eventsourcing-9.5.0b3.dist-info/licenses/AUTHORS,sha256=8aHOM4UbNZcKlD-cHpFRcM6RWyCqtwtxRev6DeUgVRs,137
|
|
34
|
+
eventsourcing-9.5.0b3.dist-info/licenses/LICENSE,sha256=CQEQzcZO8AWXL5i3hIo4yVKrYjh2FBz6hCM7kpXWpw4,1512
|
|
35
|
+
eventsourcing-9.5.0b3.dist-info/RECORD,,
|