eventsourcing 9.2.22__py3-none-any.whl → 9.3.0__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.
Potentially problematic release.
This version of eventsourcing might be problematic. Click here for more details.
- eventsourcing/__init__.py +1 -1
- eventsourcing/application.py +116 -135
- eventsourcing/cipher.py +15 -12
- eventsourcing/dispatch.py +31 -91
- eventsourcing/domain.py +220 -226
- eventsourcing/examples/__init__.py +0 -0
- eventsourcing/examples/aggregate1/__init__.py +0 -0
- eventsourcing/examples/aggregate1/application.py +27 -0
- eventsourcing/examples/aggregate1/domainmodel.py +16 -0
- eventsourcing/examples/aggregate1/test_application.py +37 -0
- eventsourcing/examples/aggregate2/__init__.py +0 -0
- eventsourcing/examples/aggregate2/application.py +27 -0
- eventsourcing/examples/aggregate2/domainmodel.py +22 -0
- eventsourcing/examples/aggregate2/test_application.py +37 -0
- eventsourcing/examples/aggregate3/__init__.py +0 -0
- eventsourcing/examples/aggregate3/application.py +27 -0
- eventsourcing/examples/aggregate3/domainmodel.py +38 -0
- eventsourcing/examples/aggregate3/test_application.py +37 -0
- eventsourcing/examples/aggregate4/__init__.py +0 -0
- eventsourcing/examples/aggregate4/application.py +27 -0
- eventsourcing/examples/aggregate4/domainmodel.py +114 -0
- eventsourcing/examples/aggregate4/test_application.py +38 -0
- eventsourcing/examples/aggregate5/__init__.py +0 -0
- eventsourcing/examples/aggregate5/application.py +27 -0
- eventsourcing/examples/aggregate5/domainmodel.py +131 -0
- eventsourcing/examples/aggregate5/test_application.py +38 -0
- eventsourcing/examples/aggregate6/__init__.py +0 -0
- eventsourcing/examples/aggregate6/application.py +30 -0
- eventsourcing/examples/aggregate6/domainmodel.py +123 -0
- eventsourcing/examples/aggregate6/test_application.py +38 -0
- eventsourcing/examples/aggregate6a/__init__.py +0 -0
- eventsourcing/examples/aggregate6a/application.py +40 -0
- eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
- eventsourcing/examples/aggregate6a/test_application.py +45 -0
- eventsourcing/examples/aggregate7/__init__.py +0 -0
- eventsourcing/examples/aggregate7/application.py +48 -0
- eventsourcing/examples/aggregate7/domainmodel.py +144 -0
- eventsourcing/examples/aggregate7/persistence.py +57 -0
- eventsourcing/examples/aggregate7/test_application.py +38 -0
- eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
- eventsourcing/examples/aggregate7a/__init__.py +0 -0
- eventsourcing/examples/aggregate7a/application.py +56 -0
- eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
- eventsourcing/examples/aggregate7a/test_application.py +46 -0
- eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate8/__init__.py +0 -0
- eventsourcing/examples/aggregate8/application.py +47 -0
- eventsourcing/examples/aggregate8/domainmodel.py +65 -0
- eventsourcing/examples/aggregate8/persistence.py +57 -0
- eventsourcing/examples/aggregate8/test_application.py +37 -0
- eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
- eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
- eventsourcing/examples/bankaccounts/__init__.py +0 -0
- eventsourcing/examples/bankaccounts/application.py +70 -0
- eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
- eventsourcing/examples/bankaccounts/test.py +173 -0
- eventsourcing/examples/cargoshipping/__init__.py +0 -0
- eventsourcing/examples/cargoshipping/application.py +126 -0
- eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
- eventsourcing/examples/cargoshipping/interface.py +143 -0
- eventsourcing/examples/cargoshipping/test.py +231 -0
- eventsourcing/examples/contentmanagement/__init__.py +0 -0
- eventsourcing/examples/contentmanagement/application.py +118 -0
- eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
- eventsourcing/examples/contentmanagement/test.py +180 -0
- eventsourcing/examples/contentmanagement/utils.py +26 -0
- eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
- eventsourcing/examples/contentmanagementsystem/application.py +54 -0
- eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
- eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
- eventsourcing/examples/contentmanagementsystem/system.py +14 -0
- eventsourcing/examples/contentmanagementsystem/test_system.py +180 -0
- eventsourcing/examples/searchablecontent/__init__.py +0 -0
- eventsourcing/examples/searchablecontent/application.py +45 -0
- eventsourcing/examples/searchablecontent/persistence.py +23 -0
- eventsourcing/examples/searchablecontent/postgres.py +118 -0
- eventsourcing/examples/searchablecontent/sqlite.py +136 -0
- eventsourcing/examples/searchablecontent/test_application.py +110 -0
- eventsourcing/examples/searchablecontent/test_recorder.py +68 -0
- eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
- eventsourcing/examples/searchabletimestamps/application.py +32 -0
- eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
- eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
- eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
- eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +94 -0
- eventsourcing/examples/test_invoice.py +176 -0
- eventsourcing/examples/test_parking_lot.py +206 -0
- eventsourcing/interface.py +2 -2
- eventsourcing/persistence.py +85 -81
- eventsourcing/popo.py +30 -31
- eventsourcing/postgres.py +379 -590
- eventsourcing/sqlite.py +91 -99
- eventsourcing/system.py +52 -57
- eventsourcing/tests/application.py +20 -32
- eventsourcing/tests/application_tests/__init__.py +0 -0
- eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
- eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
- eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
- eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
- eventsourcing/tests/application_tests/test_cache.py +134 -0
- eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
- eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
- eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
- eventsourcing/tests/application_tests/test_processapplication.py +110 -0
- eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
- eventsourcing/tests/application_tests/test_repository.py +504 -0
- eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
- eventsourcing/tests/application_tests/test_upcasting.py +459 -0
- eventsourcing/tests/docs_tests/__init__.py +0 -0
- eventsourcing/tests/docs_tests/test_docs.py +293 -0
- eventsourcing/tests/domain.py +1 -1
- eventsourcing/tests/domain_tests/__init__.py +0 -0
- eventsourcing/tests/domain_tests/test_aggregate.py +1180 -0
- eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
- eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
- eventsourcing/tests/interface_tests/__init__.py +0 -0
- eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
- eventsourcing/tests/persistence.py +52 -50
- eventsourcing/tests/persistence_tests/__init__.py +0 -0
- eventsourcing/tests/persistence_tests/test_aes.py +93 -0
- eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
- eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
- eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
- eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
- eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
- eventsourcing/tests/persistence_tests/test_popo.py +124 -0
- eventsourcing/tests/persistence_tests/test_postgres.py +1119 -0
- eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
- eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
- eventsourcing/tests/postgres_utils.py +7 -7
- eventsourcing/tests/system_tests/__init__.py +0 -0
- eventsourcing/tests/system_tests/test_runner.py +935 -0
- eventsourcing/tests/system_tests/test_system.py +284 -0
- eventsourcing/tests/utils_tests/__init__.py +0 -0
- eventsourcing/tests/utils_tests/test_utils.py +226 -0
- eventsourcing/utils.py +47 -50
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/METADATA +29 -79
- eventsourcing-9.3.0.dist-info/RECORD +145 -0
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/WHEEL +1 -2
- eventsourcing-9.2.22.dist-info/RECORD +0 -25
- eventsourcing-9.2.22.dist-info/top_level.txt +0 -1
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/AUTHORS +0 -0
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Any, List, Sequence, Tuple, cast
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from eventsourcing.domain import Aggregate
|
|
8
|
+
from eventsourcing.examples.searchabletimestamps.persistence import (
|
|
9
|
+
SearchableTimestampsRecorder,
|
|
10
|
+
)
|
|
11
|
+
from eventsourcing.sqlite import (
|
|
12
|
+
Factory,
|
|
13
|
+
SQLiteApplicationRecorder,
|
|
14
|
+
SQLiteCursor,
|
|
15
|
+
SQLiteDatastore,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
19
|
+
from eventsourcing.persistence import ApplicationRecorder, StoredEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SearchableTimestampsApplicationRecorder(
|
|
23
|
+
SearchableTimestampsRecorder, SQLiteApplicationRecorder
|
|
24
|
+
):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
datastore: SQLiteDatastore,
|
|
28
|
+
events_table_name: str = "stored_events",
|
|
29
|
+
event_timestamps_table_name: str = "event_timestamps",
|
|
30
|
+
):
|
|
31
|
+
self.event_timestamps_table_name = event_timestamps_table_name
|
|
32
|
+
super().__init__(datastore, events_table_name)
|
|
33
|
+
self.insert_event_timestamp_statement = (
|
|
34
|
+
f"INSERT INTO {self.event_timestamps_table_name} VALUES (?, ?, ?)"
|
|
35
|
+
)
|
|
36
|
+
self.select_event_timestamp_statement = (
|
|
37
|
+
f"SELECT originator_version FROM {self.event_timestamps_table_name} WHERE "
|
|
38
|
+
"originator_id = ? AND "
|
|
39
|
+
"timestamp <= ? "
|
|
40
|
+
"ORDER BY originator_version DESC "
|
|
41
|
+
"LIMIT 1"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def construct_create_table_statements(self) -> List[str]:
|
|
45
|
+
statements = super().construct_create_table_statements()
|
|
46
|
+
statements.append(
|
|
47
|
+
"CREATE TABLE IF NOT EXISTS "
|
|
48
|
+
f"{self.event_timestamps_table_name} ("
|
|
49
|
+
"originator_id TEXT, "
|
|
50
|
+
"timestamp timestamp, "
|
|
51
|
+
"originator_version INTEGER, "
|
|
52
|
+
"PRIMARY KEY "
|
|
53
|
+
"(originator_id, timestamp))"
|
|
54
|
+
)
|
|
55
|
+
return statements
|
|
56
|
+
|
|
57
|
+
def _insert_events(
|
|
58
|
+
self,
|
|
59
|
+
c: SQLiteCursor,
|
|
60
|
+
stored_events: List[StoredEvent],
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> Sequence[int] | None:
|
|
63
|
+
notification_ids = super()._insert_events(c, stored_events, **kwargs)
|
|
64
|
+
|
|
65
|
+
# Insert event timestamps.
|
|
66
|
+
event_timestamps_data = cast(
|
|
67
|
+
List[Tuple[UUID, datetime, int]], kwargs["event_timestamps_data"]
|
|
68
|
+
)
|
|
69
|
+
for originator_id, timestamp, originator_version in event_timestamps_data:
|
|
70
|
+
c.execute(
|
|
71
|
+
self.insert_event_timestamp_statement,
|
|
72
|
+
(originator_id.hex, timestamp, originator_version),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return notification_ids
|
|
76
|
+
|
|
77
|
+
def get_version_at_timestamp(
|
|
78
|
+
self, originator_id: UUID, timestamp: datetime
|
|
79
|
+
) -> int | None:
|
|
80
|
+
with self.datastore.transaction(commit=False) as c:
|
|
81
|
+
c.execute(
|
|
82
|
+
self.select_event_timestamp_statement, (originator_id.hex, timestamp)
|
|
83
|
+
)
|
|
84
|
+
for row in c.fetchall():
|
|
85
|
+
version = row["originator_version"]
|
|
86
|
+
break
|
|
87
|
+
else:
|
|
88
|
+
version = Aggregate.INITIAL_VERSION - 1
|
|
89
|
+
return version
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SearchableTimestampsInfrastructureFactory(Factory):
|
|
93
|
+
def application_recorder(self) -> ApplicationRecorder:
|
|
94
|
+
recorder = SearchableTimestampsApplicationRecorder(datastore=self.datastore)
|
|
95
|
+
recorder.create_table()
|
|
96
|
+
return recorder
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
del Factory
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from time import sleep
|
|
6
|
+
from typing import ClassVar, Dict
|
|
7
|
+
from unittest import TestCase
|
|
8
|
+
|
|
9
|
+
from eventsourcing.application import AggregateNotFoundError
|
|
10
|
+
from eventsourcing.domain import create_utc_datetime_now
|
|
11
|
+
from eventsourcing.examples.cargoshipping.domainmodel import Location
|
|
12
|
+
from eventsourcing.examples.searchabletimestamps.application import (
|
|
13
|
+
SearchableTimestampsApplication,
|
|
14
|
+
)
|
|
15
|
+
from eventsourcing.postgres import PostgresDatastore
|
|
16
|
+
from eventsourcing.tests.postgres_utils import drop_postgres_table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchableTimestampsTestCase(TestCase):
|
|
20
|
+
env: ClassVar[Dict[str, str]]
|
|
21
|
+
|
|
22
|
+
def test(self) -> None:
|
|
23
|
+
# Construct application.
|
|
24
|
+
app = SearchableTimestampsApplication(env=self.env)
|
|
25
|
+
timestamp0 = create_utc_datetime_now()
|
|
26
|
+
sleep(1e-5)
|
|
27
|
+
|
|
28
|
+
# Book new cargo.
|
|
29
|
+
tracking_id = app.book_new_cargo(
|
|
30
|
+
origin=Location["NLRTM"],
|
|
31
|
+
destination=Location["USDAL"],
|
|
32
|
+
arrival_deadline=create_utc_datetime_now() + timedelta(weeks=3),
|
|
33
|
+
)
|
|
34
|
+
timestamp1 = create_utc_datetime_now()
|
|
35
|
+
sleep(1e-5)
|
|
36
|
+
|
|
37
|
+
# Change destination.
|
|
38
|
+
app.change_destination(tracking_id, destination=Location["AUMEL"])
|
|
39
|
+
timestamp2 = create_utc_datetime_now()
|
|
40
|
+
sleep(1e-5)
|
|
41
|
+
|
|
42
|
+
# View the state of the cargo tracking at particular times.
|
|
43
|
+
with self.assertRaises(AggregateNotFoundError):
|
|
44
|
+
app.get_cargo_at_timestamp(tracking_id, timestamp0)
|
|
45
|
+
|
|
46
|
+
cargo_at_timestamp1 = app.get_cargo_at_timestamp(tracking_id, timestamp1)
|
|
47
|
+
self.assertEqual(cargo_at_timestamp1.destination, Location["USDAL"])
|
|
48
|
+
|
|
49
|
+
cargo_at_timestamp2 = app.get_cargo_at_timestamp(tracking_id, timestamp2)
|
|
50
|
+
self.assertEqual(cargo_at_timestamp2.destination, Location["AUMEL"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WithSQLite(SearchableTimestampsTestCase):
|
|
54
|
+
env: ClassVar[Dict[str, str]] = {
|
|
55
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchabletimestamps.sqlite",
|
|
56
|
+
"SQLITE_DBNAME": ":memory:",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WithPostgreSQL(SearchableTimestampsTestCase):
|
|
61
|
+
env: ClassVar[Dict[str, str]] = {
|
|
62
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchabletimestamps.postgres"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def setUp(self) -> None:
|
|
66
|
+
super().setUp()
|
|
67
|
+
os.environ["POSTGRES_DBNAME"] = "eventsourcing"
|
|
68
|
+
os.environ["POSTGRES_HOST"] = "127.0.0.1"
|
|
69
|
+
os.environ["POSTGRES_PORT"] = "5432"
|
|
70
|
+
os.environ["POSTGRES_USER"] = "eventsourcing"
|
|
71
|
+
os.environ["POSTGRES_PASSWORD"] = "eventsourcing" # noqa: S105
|
|
72
|
+
self.drop_tables()
|
|
73
|
+
|
|
74
|
+
def tearDown(self) -> None:
|
|
75
|
+
self.drop_tables()
|
|
76
|
+
super().tearDown()
|
|
77
|
+
|
|
78
|
+
def drop_tables(self) -> None:
|
|
79
|
+
with PostgresDatastore(
|
|
80
|
+
os.environ["POSTGRES_DBNAME"],
|
|
81
|
+
os.environ["POSTGRES_HOST"],
|
|
82
|
+
os.environ["POSTGRES_PORT"],
|
|
83
|
+
os.environ["POSTGRES_USER"],
|
|
84
|
+
os.environ["POSTGRES_PASSWORD"],
|
|
85
|
+
) as datastore:
|
|
86
|
+
drop_postgres_table(
|
|
87
|
+
datastore, "public.searchabletimestampsapplication_events"
|
|
88
|
+
)
|
|
89
|
+
drop_postgres_table(
|
|
90
|
+
datastore, "public.searchabletimestampsapplication_timestamps"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
del SearchableTimestampsTestCase
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, cast
|
|
7
|
+
from unittest import TestCase
|
|
8
|
+
|
|
9
|
+
from eventsourcing.application import Application
|
|
10
|
+
from eventsourcing.domain import Aggregate, Snapshot, event
|
|
11
|
+
from eventsourcing.persistence import Transcoding
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Status(Enum):
|
|
18
|
+
INITIATED = 1
|
|
19
|
+
ISSUED = 2
|
|
20
|
+
SENT = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SendMethod(Enum):
|
|
24
|
+
EMAIL = 1
|
|
25
|
+
POST = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Person:
|
|
30
|
+
name: str
|
|
31
|
+
address: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Invoice(Aggregate):
|
|
35
|
+
@event("Initiated")
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
number: str,
|
|
39
|
+
amount: Decimal,
|
|
40
|
+
issued_to: Person,
|
|
41
|
+
timestamp: datetime | Any = None,
|
|
42
|
+
):
|
|
43
|
+
self._number = number
|
|
44
|
+
self._amount = amount
|
|
45
|
+
self.issued_to = issued_to
|
|
46
|
+
self.initiated_at = timestamp
|
|
47
|
+
self.status = Status.INITIATED
|
|
48
|
+
|
|
49
|
+
def _get_number(self) -> str:
|
|
50
|
+
return self._number
|
|
51
|
+
|
|
52
|
+
@event
|
|
53
|
+
def _number_updated(self, value: str) -> None:
|
|
54
|
+
assert self.status == Status.INITIATED
|
|
55
|
+
self._number = value
|
|
56
|
+
|
|
57
|
+
number = property(_get_number, _number_updated)
|
|
58
|
+
|
|
59
|
+
def _get_amount(self) -> Decimal:
|
|
60
|
+
return self._amount
|
|
61
|
+
|
|
62
|
+
@event("AmountUpdated")
|
|
63
|
+
def _set_amount(self, value: Decimal) -> None:
|
|
64
|
+
assert self.status == Status.INITIATED
|
|
65
|
+
self._amount = value
|
|
66
|
+
|
|
67
|
+
amount = property(_get_amount, _set_amount)
|
|
68
|
+
|
|
69
|
+
@event("Issued")
|
|
70
|
+
def issue(self, issued_by: str, timestamp: datetime | Any = None) -> None:
|
|
71
|
+
self.issued_by = issued_by
|
|
72
|
+
self.issued_on = timestamp
|
|
73
|
+
self.status = Status.ISSUED
|
|
74
|
+
|
|
75
|
+
@event("Sent")
|
|
76
|
+
def send(self, sent_via: SendMethod, timestamp: datetime | Any = None) -> None:
|
|
77
|
+
self.sent_via = sent_via
|
|
78
|
+
self.sent_at = timestamp
|
|
79
|
+
self.status = Status.SENT
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PersonAsDict(Transcoding):
|
|
83
|
+
name = "person_as_dict"
|
|
84
|
+
type = Person
|
|
85
|
+
|
|
86
|
+
def encode(self, obj: Person) -> Dict[str, Any]:
|
|
87
|
+
return obj.__dict__
|
|
88
|
+
|
|
89
|
+
def decode(self, data: Dict[str, Any]) -> Person:
|
|
90
|
+
return Person(**data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SendMethodAsStr(Transcoding):
|
|
94
|
+
name = "send_method_str"
|
|
95
|
+
type = SendMethod
|
|
96
|
+
|
|
97
|
+
def encode(self, obj: SendMethod) -> str:
|
|
98
|
+
return obj.name
|
|
99
|
+
|
|
100
|
+
def decode(self, data: str) -> SendMethod:
|
|
101
|
+
return getattr(SendMethod, data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StatusAsStr(Transcoding):
|
|
105
|
+
name = "status_str"
|
|
106
|
+
type = Status
|
|
107
|
+
|
|
108
|
+
def encode(self, obj: Status) -> str:
|
|
109
|
+
return obj.name
|
|
110
|
+
|
|
111
|
+
def decode(self, data: str) -> Status:
|
|
112
|
+
return getattr(Status, data)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestInvoice(TestCase):
|
|
116
|
+
def test(self) -> None:
|
|
117
|
+
invoice = Invoice(
|
|
118
|
+
number="INV/2021/11/01",
|
|
119
|
+
amount=Decimal("34.20"),
|
|
120
|
+
issued_to=Person("Oscar the Grouch", "123 Sesame Street"),
|
|
121
|
+
)
|
|
122
|
+
self.assertEqual(invoice.number, "INV/2021/11/01")
|
|
123
|
+
self.assertEqual(invoice.amount, Decimal("34.20"))
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
invoice.issued_to, Person("Oscar the Grouch", "123 Sesame Street")
|
|
126
|
+
)
|
|
127
|
+
self.assertEqual(invoice.status, Status.INITIATED)
|
|
128
|
+
|
|
129
|
+
invoice.number = "INV/2021/11/02"
|
|
130
|
+
self.assertEqual(invoice.number, "INV/2021/11/02")
|
|
131
|
+
|
|
132
|
+
invoice.amount = Decimal("43.20")
|
|
133
|
+
self.assertEqual(invoice.number, "INV/2021/11/02")
|
|
134
|
+
|
|
135
|
+
invoice.issue(issued_by="Cookie Monster")
|
|
136
|
+
self.assertEqual(invoice.issued_by, "Cookie Monster")
|
|
137
|
+
self.assertEqual(invoice.status, Status.ISSUED)
|
|
138
|
+
|
|
139
|
+
with self.assertRaises(AssertionError):
|
|
140
|
+
invoice.number = "INV/2021/11/03"
|
|
141
|
+
|
|
142
|
+
with self.assertRaises(AssertionError):
|
|
143
|
+
invoice.amount = Decimal("54.20")
|
|
144
|
+
|
|
145
|
+
invoice.send(sent_via=SendMethod.EMAIL)
|
|
146
|
+
self.assertEqual(invoice.sent_via, SendMethod.EMAIL)
|
|
147
|
+
self.assertEqual(invoice.status, Status.SENT)
|
|
148
|
+
|
|
149
|
+
app: Application = Application(env={"IS_SNAPSHOTTING_ENABLED": "y"})
|
|
150
|
+
app.mapper.transcoder.register(PersonAsDict())
|
|
151
|
+
app.mapper.transcoder.register(SendMethodAsStr())
|
|
152
|
+
app.mapper.transcoder.register(StatusAsStr())
|
|
153
|
+
|
|
154
|
+
app.save(invoice)
|
|
155
|
+
|
|
156
|
+
copy: Invoice = app.repository.get(invoice.id)
|
|
157
|
+
self.assertEqual(invoice, copy)
|
|
158
|
+
|
|
159
|
+
assert app.snapshots is not None
|
|
160
|
+
snapshots = list(app.snapshots.get(invoice.id))
|
|
161
|
+
self.assertEqual(len(snapshots), 0)
|
|
162
|
+
|
|
163
|
+
app.take_snapshot(invoice.id)
|
|
164
|
+
|
|
165
|
+
copy = app.repository.get(invoice.id)
|
|
166
|
+
self.assertEqual(invoice, copy)
|
|
167
|
+
|
|
168
|
+
copy = app.repository.get(invoice.id, version=1)
|
|
169
|
+
self.assertNotEqual(invoice, copy)
|
|
170
|
+
|
|
171
|
+
snapshots = list(app.snapshots.get(invoice.id))
|
|
172
|
+
self.assertEqual(len(snapshots), 1)
|
|
173
|
+
|
|
174
|
+
snapshot = cast(Snapshot, snapshots[0])
|
|
175
|
+
copy2 = cast(Invoice, snapshot.mutate(None))
|
|
176
|
+
self.assertEqual(invoice, copy2)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
After Ed Blackburn's https://github.com/edblackburn/parking-lot/.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import List, Type, cast
|
|
11
|
+
from unittest import TestCase
|
|
12
|
+
from uuid import NAMESPACE_URL, UUID, uuid5
|
|
13
|
+
|
|
14
|
+
from eventsourcing.application import AggregateNotFoundError, Application
|
|
15
|
+
from eventsourcing.domain import Aggregate, triggers
|
|
16
|
+
from eventsourcing.system import NotificationLogReader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LicencePlate:
|
|
21
|
+
number: str
|
|
22
|
+
regex = re.compile("^[0-9]{3}-[0-9]{3}$")
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not bool(self.regex.match(self.number)):
|
|
26
|
+
raise ValueError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Booking:
|
|
31
|
+
start: datetime
|
|
32
|
+
finish: datetime
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Product:
|
|
36
|
+
delta = timedelta()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def calc_finish(cls, start: datetime) -> datetime:
|
|
40
|
+
return start + cls.delta
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EndOfDay(Product):
|
|
44
|
+
delta = timedelta(days=1, seconds=-1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EndOfWeek(Product):
|
|
48
|
+
delta = timedelta(days=7, seconds=-1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Vehicle(Aggregate):
|
|
52
|
+
class Event(Aggregate.Event):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class Registered(Event, Aggregate.Created):
|
|
56
|
+
licence_plate_number: str
|
|
57
|
+
|
|
58
|
+
class Booked(Event):
|
|
59
|
+
start: datetime
|
|
60
|
+
finish: datetime
|
|
61
|
+
|
|
62
|
+
class Unbooked(Event):
|
|
63
|
+
when: datetime
|
|
64
|
+
|
|
65
|
+
@triggers(Registered)
|
|
66
|
+
def __init__(self, licence_plate_number: str):
|
|
67
|
+
self.licence_plate_number = licence_plate_number
|
|
68
|
+
self.bookings: List[Booking] = []
|
|
69
|
+
self.inspection_failures: List[datetime] = []
|
|
70
|
+
|
|
71
|
+
@triggers(Booked)
|
|
72
|
+
def book(self, start: datetime, finish: datetime) -> None:
|
|
73
|
+
self.bookings.append(Booking(start, finish))
|
|
74
|
+
|
|
75
|
+
@triggers(Unbooked)
|
|
76
|
+
def fail_inspection(self, when: datetime) -> None:
|
|
77
|
+
self.inspection_failures.append(when)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def licence_plate(self) -> LicencePlate:
|
|
81
|
+
return LicencePlate(self.licence_plate_number)
|
|
82
|
+
|
|
83
|
+
def inspect(self, when: datetime) -> None:
|
|
84
|
+
for booking in self.bookings:
|
|
85
|
+
if booking.start < when < booking.finish:
|
|
86
|
+
break
|
|
87
|
+
else:
|
|
88
|
+
self.fail_inspection(when)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def create_id(licence_plate_number: str) -> UUID:
|
|
92
|
+
return uuid5(NAMESPACE_URL, f"/licence_plate_numbers/{licence_plate_number}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ParkingLot(Application):
|
|
96
|
+
def book(self, licence_plate: LicencePlate, product: Type[Product]) -> None:
|
|
97
|
+
try:
|
|
98
|
+
vehicle = self.get_vehicle(licence_plate)
|
|
99
|
+
except AggregateNotFoundError:
|
|
100
|
+
vehicle = Vehicle(licence_plate.number)
|
|
101
|
+
start = Vehicle.Event.create_timestamp()
|
|
102
|
+
finish = product.calc_finish(start)
|
|
103
|
+
vehicle.book(start=start, finish=finish)
|
|
104
|
+
self.save(vehicle)
|
|
105
|
+
|
|
106
|
+
def inspect(self, licence_plate: LicencePlate, when: datetime) -> None:
|
|
107
|
+
vehicle = self.get_vehicle(licence_plate)
|
|
108
|
+
vehicle.inspect(when)
|
|
109
|
+
self.save(vehicle)
|
|
110
|
+
|
|
111
|
+
def get_vehicle(self, licence_plate: LicencePlate) -> Vehicle:
|
|
112
|
+
return cast(
|
|
113
|
+
Vehicle, self.repository.get(Vehicle.create_id(licence_plate.number))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestParkingLot(TestCase):
|
|
118
|
+
def test_licence_plate(self) -> None:
|
|
119
|
+
# Valid.
|
|
120
|
+
licence_plate = LicencePlate("123-123")
|
|
121
|
+
self.assertIsInstance(licence_plate, LicencePlate)
|
|
122
|
+
self.assertEqual(licence_plate.number, "123-123")
|
|
123
|
+
|
|
124
|
+
# Invalid.
|
|
125
|
+
with self.assertRaises(ValueError):
|
|
126
|
+
LicencePlate("abcdef")
|
|
127
|
+
|
|
128
|
+
def test_parking_lot(self) -> None:
|
|
129
|
+
# Construct the application object to use an SQLite database.
|
|
130
|
+
app = ParkingLot(
|
|
131
|
+
env={
|
|
132
|
+
"PERSISTENCE_MODULE": "eventsourcing.sqlite",
|
|
133
|
+
"SQLITE_DBNAME": ":memory:",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Create a valid licence plate.
|
|
138
|
+
licence_plate = LicencePlate("123-123")
|
|
139
|
+
|
|
140
|
+
# Book unregistered vehicle.
|
|
141
|
+
app.book(licence_plate, EndOfDay)
|
|
142
|
+
|
|
143
|
+
# Check vehicle state.
|
|
144
|
+
vehicle = app.get_vehicle(licence_plate)
|
|
145
|
+
self.assertEqual(vehicle.licence_plate, licence_plate)
|
|
146
|
+
self.assertEqual(len(vehicle.bookings), 1)
|
|
147
|
+
self.assertEqual(len(vehicle.inspection_failures), 0)
|
|
148
|
+
booking1 = vehicle.bookings[-1]
|
|
149
|
+
|
|
150
|
+
# Book registered vehicle.
|
|
151
|
+
app.book(licence_plate, EndOfWeek)
|
|
152
|
+
|
|
153
|
+
# Check vehicle state.
|
|
154
|
+
vehicle = app.get_vehicle(licence_plate)
|
|
155
|
+
self.assertEqual(len(vehicle.bookings), 2)
|
|
156
|
+
self.assertEqual(len(vehicle.inspection_failures), 0)
|
|
157
|
+
booking2 = vehicle.bookings[-1]
|
|
158
|
+
|
|
159
|
+
# Inspect whilst has booking.
|
|
160
|
+
app.inspect(licence_plate, Vehicle.Event.create_timestamp())
|
|
161
|
+
|
|
162
|
+
# Check vehicle state.
|
|
163
|
+
vehicle = app.get_vehicle(licence_plate)
|
|
164
|
+
self.assertEqual(len(vehicle.bookings), 2)
|
|
165
|
+
self.assertEqual(len(vehicle.inspection_failures), 0)
|
|
166
|
+
|
|
167
|
+
# Inspect after bookings expired.
|
|
168
|
+
inspected_on = Vehicle.Event.create_timestamp() + timedelta(days=10)
|
|
169
|
+
app.inspect(licence_plate, inspected_on)
|
|
170
|
+
|
|
171
|
+
# Check vehicle state.
|
|
172
|
+
vehicle = app.get_vehicle(licence_plate)
|
|
173
|
+
self.assertEqual(len(vehicle.bookings), 2)
|
|
174
|
+
self.assertEqual(len(vehicle.inspection_failures), 1)
|
|
175
|
+
|
|
176
|
+
# Check all domain events in bounded context.
|
|
177
|
+
notifications = NotificationLogReader(app.notification_log).read(start=1)
|
|
178
|
+
domain_events = [app.mapper.to_domain_event(n) for n in notifications]
|
|
179
|
+
self.assertEqual(len(domain_events), 4)
|
|
180
|
+
|
|
181
|
+
vehicle1_id = Vehicle.create_id("123-123")
|
|
182
|
+
event0 = domain_events[0]
|
|
183
|
+
assert isinstance(event0, Vehicle.Registered)
|
|
184
|
+
self.assertEqual(event0.originator_id, vehicle1_id)
|
|
185
|
+
self.assertEqual(event0.originator_version, 1)
|
|
186
|
+
self.assertEqual(event0.licence_plate_number, "123-123")
|
|
187
|
+
|
|
188
|
+
event1 = domain_events[1]
|
|
189
|
+
assert isinstance(event1, Vehicle.Booked)
|
|
190
|
+
self.assertEqual(event1.originator_id, vehicle1_id)
|
|
191
|
+
self.assertEqual(event1.originator_version, 2)
|
|
192
|
+
self.assertEqual(event1.start, booking1.start)
|
|
193
|
+
self.assertEqual(event1.finish, booking1.finish)
|
|
194
|
+
|
|
195
|
+
event2 = domain_events[2]
|
|
196
|
+
assert isinstance(event2, Vehicle.Booked)
|
|
197
|
+
self.assertEqual(event2.originator_id, vehicle1_id)
|
|
198
|
+
self.assertEqual(event2.originator_version, 3)
|
|
199
|
+
self.assertEqual(event2.start, booking2.start)
|
|
200
|
+
self.assertEqual(event2.finish, booking2.finish)
|
|
201
|
+
|
|
202
|
+
event3 = domain_events[3]
|
|
203
|
+
assert isinstance(event3, Vehicle.Unbooked)
|
|
204
|
+
self.assertEqual(event3.originator_id, vehicle1_id)
|
|
205
|
+
self.assertEqual(event3.originator_version, 4)
|
|
206
|
+
self.assertEqual(event3.when, inspected_on)
|
eventsourcing/interface.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from base64 import b64decode, b64encode
|
|
6
|
-
from typing import Generic, List,
|
|
6
|
+
from typing import Generic, List, Sequence
|
|
7
7
|
from uuid import UUID
|
|
8
8
|
|
|
9
9
|
from eventsourcing.application import NotificationLog, Section, TApplication
|
|
@@ -125,7 +125,7 @@ class NotificationLogJSONClient(NotificationLog):
|
|
|
125
125
|
self,
|
|
126
126
|
start: int,
|
|
127
127
|
limit: int,
|
|
128
|
-
|
|
128
|
+
_: int | None = None,
|
|
129
129
|
topics: Sequence[str] = (),
|
|
130
130
|
) -> List[Notification]:
|
|
131
131
|
"""
|