python-event-sourcery 0.4.0a0__tar.gz → 0.4.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/PKG-INFO +6 -7
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/__init__.py +5 -1
- python_event_sourcery-0.4.0a1/event_sourcery_sqlalchemy/dto.py +18 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/event_store.py +4 -2
- python_event_sourcery-0.4.0a1/event_sourcery_sqlalchemy/subscription.py +176 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/pyproject.toml +22 -9
- python_event_sourcery-0.4.0a0/event_sourcery_kombu/__init__.py +0 -155
- python_event_sourcery-0.4.0a0/event_sourcery_sqlalchemy/subscription.py +0 -33
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/LICENSE +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/aggregate/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/aggregate/aggregate.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/aggregate/repository.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/dispatcher.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/event/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/event/dto.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/event/registry.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/event/serde.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/event_store.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/exceptions.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/factory.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/in_memory.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/interfaces.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/outbox.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/stream_id.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/subscription.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/versioning.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/py.typed +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/read_model/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/read_model/cursors_dao.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/read_model/projector.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/__init__.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/dto.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/event_store.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/outbox.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/py.typed +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/stream.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/subscription.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/cursors_dao.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/guid.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/jsonb.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/models.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/outbox.py +0 -0
- {python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-event-sourcery
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.0a1
|
|
4
4
|
Summary:
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Your Name
|
|
@@ -10,13 +10,12 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
-
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Provides-Extra: django
|
|
15
|
-
Provides-Extra:
|
|
16
|
-
|
|
17
|
-
Requires-Dist:
|
|
15
|
+
Provides-Extra: esdb
|
|
16
|
+
Provides-Extra: sqlalchemy
|
|
17
|
+
Requires-Dist: SQLAlchemy (>=2.0) ; extra == "sqlalchemy"
|
|
18
18
|
Requires-Dist: django (>=4,<6) ; extra == "django"
|
|
19
|
-
Requires-Dist: esdbclient (>=1.0b3)
|
|
20
|
-
Requires-Dist: kombu ; extra == "kombu"
|
|
19
|
+
Requires-Dist: esdbclient (>=1.0b3) ; extra == "esdb"
|
|
21
20
|
Requires-Dist: more-itertools
|
|
22
21
|
Requires-Dist: pydantic (>=2.0,<3)
|
|
@@ -7,6 +7,7 @@ __all__ = [
|
|
|
7
7
|
]
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
+
from datetime import timedelta
|
|
10
11
|
|
|
11
12
|
from pydantic import BaseModel, ConfigDict, PositiveInt
|
|
12
13
|
from sqlalchemy.orm import Session
|
|
@@ -36,6 +37,7 @@ class Config(BaseModel):
|
|
|
36
37
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
37
38
|
|
|
38
39
|
outbox_attempts: PositiveInt = 3
|
|
40
|
+
gap_retry_interval: timedelta = timedelta(seconds=0.5)
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
@dataclass(repr=False)
|
|
@@ -63,7 +65,9 @@ class SQLAlchemyBackendFactory(BackendFactory):
|
|
|
63
65
|
)
|
|
64
66
|
backend.subscriber = es.subscription.SubscriptionBuilder(
|
|
65
67
|
_serde=backend.serde,
|
|
66
|
-
_strategy=SqlAlchemySubscriptionStrategy(
|
|
68
|
+
_strategy=SqlAlchemySubscriptionStrategy(
|
|
69
|
+
self._session, self._config.gap_retry_interval
|
|
70
|
+
),
|
|
67
71
|
)
|
|
68
72
|
return backend
|
|
69
73
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from event_sourcery.event_store import RawEvent, StreamId
|
|
2
|
+
from event_sourcery_sqlalchemy.models import Event, Stream
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def raw_event(from_entry: Event, in_stream: Stream) -> RawEvent:
|
|
6
|
+
return RawEvent(
|
|
7
|
+
uuid=from_entry.uuid,
|
|
8
|
+
stream_id=StreamId(
|
|
9
|
+
uuid=in_stream.uuid,
|
|
10
|
+
name=in_stream.name,
|
|
11
|
+
category=None if in_stream.category == "" else in_stream.category,
|
|
12
|
+
),
|
|
13
|
+
created_at=from_entry.created_at,
|
|
14
|
+
version=from_entry.version,
|
|
15
|
+
name=from_entry.name,
|
|
16
|
+
data=from_entry.data,
|
|
17
|
+
context=from_entry.event_context,
|
|
18
|
+
)
|
|
@@ -2,7 +2,7 @@ from dataclasses import dataclass
|
|
|
2
2
|
from typing import Sequence, Union, cast
|
|
3
3
|
|
|
4
4
|
from more_itertools import first_true
|
|
5
|
-
from sqlalchemy import delete, select, update
|
|
5
|
+
from sqlalchemy import delete, func, select, update
|
|
6
6
|
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
|
7
7
|
from sqlalchemy.exc import NoResultFound
|
|
8
8
|
from sqlalchemy.orm import Session
|
|
@@ -221,4 +221,6 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
|
|
|
221
221
|
|
|
222
222
|
@property
|
|
223
223
|
def current_position(self) -> Position | None:
|
|
224
|
-
|
|
224
|
+
stmt = select(func.max(EventModel.id))
|
|
225
|
+
last_event = self._session.scalar(stmt)
|
|
226
|
+
return last_event or Position(0)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Iterator, Protocol, cast
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from event_sourcery.event_store import Position, RecordedRaw
|
|
10
|
+
from event_sourcery.event_store.interfaces import SubscriptionStrategy
|
|
11
|
+
from event_sourcery_sqlalchemy import dto, models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SqlAlchemySubscriptionStrategy(SubscriptionStrategy):
|
|
15
|
+
def __init__(self, session: Session, gap_retry_interval: timedelta) -> None:
|
|
16
|
+
self._session = session
|
|
17
|
+
self._gap_retry_interval = gap_retry_interval
|
|
18
|
+
|
|
19
|
+
def subscribe_to_all(
|
|
20
|
+
self,
|
|
21
|
+
start_from: Position,
|
|
22
|
+
batch_size: int,
|
|
23
|
+
timelimit: timedelta,
|
|
24
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
25
|
+
return GapDetectingIterator(
|
|
26
|
+
get_batch=GetBatchToAll(self._session, batch_size),
|
|
27
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
28
|
+
start_from=start_from,
|
|
29
|
+
batch_size=batch_size,
|
|
30
|
+
timelimit=timelimit,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def subscribe_to_category(
|
|
34
|
+
self,
|
|
35
|
+
start_from: Position,
|
|
36
|
+
batch_size: int,
|
|
37
|
+
timelimit: timedelta,
|
|
38
|
+
category: str,
|
|
39
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
40
|
+
return GapDetectingIterator(
|
|
41
|
+
get_batch=GetBatchToCategory(self._session, batch_size, category),
|
|
42
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
43
|
+
start_from=start_from,
|
|
44
|
+
batch_size=batch_size,
|
|
45
|
+
timelimit=timelimit,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def subscribe_to_events(
|
|
49
|
+
self,
|
|
50
|
+
start_from: Position,
|
|
51
|
+
batch_size: int,
|
|
52
|
+
timelimit: timedelta,
|
|
53
|
+
events: list[str],
|
|
54
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
55
|
+
return GapDetectingIterator(
|
|
56
|
+
get_batch=GetBatchToEvents(self._session, batch_size, events),
|
|
57
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
58
|
+
start_from=start_from,
|
|
59
|
+
batch_size=batch_size,
|
|
60
|
+
timelimit=timelimit,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GetBatch(Protocol):
|
|
65
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GetBatchToAll(GetBatch):
|
|
70
|
+
def __init__(self, session: Session, batch_size: int) -> None:
|
|
71
|
+
self._session = session
|
|
72
|
+
self._batch_size = batch_size
|
|
73
|
+
|
|
74
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
75
|
+
stmt = (
|
|
76
|
+
select(models.Event)
|
|
77
|
+
.join(models.Stream)
|
|
78
|
+
.where(models.Event.id > position)
|
|
79
|
+
.order_by(models.Event.id)
|
|
80
|
+
.limit(self._batch_size)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return list(self._session.scalars(stmt).all())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GetBatchToCategory(GetBatch):
|
|
87
|
+
def __init__(self, session: Session, batch_size: int, category: str) -> None:
|
|
88
|
+
self._session = session
|
|
89
|
+
self._batch_size = batch_size
|
|
90
|
+
self._category = category
|
|
91
|
+
|
|
92
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
93
|
+
stmt = (
|
|
94
|
+
select(models.Event)
|
|
95
|
+
.join(models.Stream)
|
|
96
|
+
.where(models.Stream.category == self._category)
|
|
97
|
+
.where(models.Event.id > position)
|
|
98
|
+
.order_by(models.Event.id)
|
|
99
|
+
.limit(self._batch_size)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return list(self._session.scalars(stmt).all())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class GetBatchToEvents(GetBatch):
|
|
106
|
+
def __init__(self, session: Session, batch_size: int, events: list[str]) -> None:
|
|
107
|
+
self._session = session
|
|
108
|
+
self._batch_size = batch_size
|
|
109
|
+
self._events = events
|
|
110
|
+
|
|
111
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
112
|
+
stmt = (
|
|
113
|
+
select(models.Event)
|
|
114
|
+
.join(models.Stream)
|
|
115
|
+
.where(models.Event.name.in_(self._events))
|
|
116
|
+
.where(models.Event.id > position)
|
|
117
|
+
.order_by(models.Event.id)
|
|
118
|
+
.limit(self._batch_size)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return list(self._session.scalars(stmt).all())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Cursor:
|
|
126
|
+
position: Position
|
|
127
|
+
|
|
128
|
+
def advance(self, batch: list[models.Event]) -> None:
|
|
129
|
+
if len(batch) > 0:
|
|
130
|
+
self.position = batch[-1].id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GapDetectingIterator(Iterator[list[RecordedRaw]]):
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
get_batch: GetBatch,
|
|
137
|
+
gap_retry_interval: timedelta,
|
|
138
|
+
start_from: Position,
|
|
139
|
+
batch_size: int,
|
|
140
|
+
timelimit: timedelta,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._get_batch = get_batch
|
|
143
|
+
self._gap_retry_interval = gap_retry_interval
|
|
144
|
+
self._cursor = Cursor(position=start_from)
|
|
145
|
+
self._batch_size = batch_size
|
|
146
|
+
self._timelimit = timelimit
|
|
147
|
+
|
|
148
|
+
def __next__(self) -> list[RecordedRaw]:
|
|
149
|
+
start = time.monotonic()
|
|
150
|
+
while True:
|
|
151
|
+
batch = self._get_batch(self._cursor.position)
|
|
152
|
+
if self._is_continuous(batch) and len(batch) == self._batch_size:
|
|
153
|
+
self._cursor.advance(batch)
|
|
154
|
+
return self._batch_to_recorded_raw(batch)
|
|
155
|
+
elif time.monotonic() - start > self._timelimit.total_seconds():
|
|
156
|
+
self._cursor.advance(batch)
|
|
157
|
+
return self._batch_to_recorded_raw(batch)
|
|
158
|
+
else:
|
|
159
|
+
time.sleep(self._gap_retry_interval.total_seconds())
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _is_continuous(batch: list[models.Event]) -> bool:
|
|
163
|
+
if len(batch) < 2:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
return cast(bool, batch[-1].id - batch[0].id + 1 == len(batch))
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _batch_to_recorded_raw(batch: list[models.Event]) -> list[RecordedRaw]:
|
|
170
|
+
return [
|
|
171
|
+
RecordedRaw(
|
|
172
|
+
entry=dto.raw_event(event, event.stream),
|
|
173
|
+
position=event.id,
|
|
174
|
+
)
|
|
175
|
+
for event in batch
|
|
176
|
+
]
|
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-event-sourcery"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.0a1"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = ["Your Name <you@example.com>"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
packages = [
|
|
8
8
|
{ include = "event_sourcery" },
|
|
9
9
|
{ include = "event_sourcery_esdb" },
|
|
10
|
-
{ include = "event_sourcery_kombu" },
|
|
11
10
|
{ include = "event_sourcery_sqlalchemy" },
|
|
12
11
|
]
|
|
13
12
|
|
|
14
13
|
[tool.poetry.dependencies]
|
|
15
14
|
python = ">=3.10,<4.0"
|
|
16
15
|
pydantic = ">=2.0,<3"
|
|
17
|
-
SQLAlchemy = ">=2.0"
|
|
18
|
-
esdbclient = ">=1.0b3"
|
|
19
|
-
kombu = {version = "*", optional = true}
|
|
20
|
-
cloudevents = {version = "*", optional = true}
|
|
21
16
|
more-itertools = "*"
|
|
17
|
+
SQLAlchemy = {version = ">=2.0", optional = true}
|
|
18
|
+
esdbclient = {version = ">=1.0b3", optional = true}
|
|
22
19
|
django = {version = ">=4,<6", optional = true}
|
|
23
20
|
|
|
24
21
|
[tool.poetry.group.dev.dependencies]
|
|
@@ -32,10 +29,11 @@ pytest-cov = "*"
|
|
|
32
29
|
mkdocs-material = "^8.3.9"
|
|
33
30
|
psycopg2-binary = "^2.9.3"
|
|
34
31
|
pytest-django = {version = "*"}
|
|
32
|
+
flake8-pyproject = "*"
|
|
35
33
|
|
|
36
34
|
[tool.poetry.extras]
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
sqlalchemy = ["SQLAlchemy"]
|
|
36
|
+
esdb = ["esdbclient"]
|
|
39
37
|
django = ["django"]
|
|
40
38
|
|
|
41
39
|
[tool.pytest]
|
|
@@ -57,7 +55,6 @@ source = [
|
|
|
57
55
|
"event_sourcery_sqlalchemy",
|
|
58
56
|
]
|
|
59
57
|
omit = [
|
|
60
|
-
"event_sourcery_kombu/*",
|
|
61
58
|
"tests/*",
|
|
62
59
|
]
|
|
63
60
|
|
|
@@ -75,6 +72,22 @@ exclude_lines = [
|
|
|
75
72
|
"TYPE_CHECKING",
|
|
76
73
|
]
|
|
77
74
|
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.10"
|
|
77
|
+
ignore_missing_imports = true
|
|
78
|
+
disallow_untyped_defs = true
|
|
79
|
+
no_implicit_optional = true
|
|
80
|
+
check_untyped_defs = true
|
|
81
|
+
warn_return_any = true
|
|
82
|
+
warn_unused_ignores = true
|
|
83
|
+
show_error_codes = true
|
|
84
|
+
|
|
85
|
+
[tool.flake8]
|
|
86
|
+
max-line-length = 88
|
|
87
|
+
|
|
88
|
+
[tool.isort]
|
|
89
|
+
profile = "black"
|
|
90
|
+
|
|
78
91
|
[build-system]
|
|
79
92
|
requires = ["poetry-core>=1.0.0"]
|
|
80
93
|
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
from typing import Callable, Type
|
|
3
|
-
|
|
4
|
-
from kombu import Connection, Exchange, Message, Queue
|
|
5
|
-
from kombu.connection import ConnectionPool
|
|
6
|
-
from kombu.pools import connections
|
|
7
|
-
from pydantic import AmqpDsn, Field
|
|
8
|
-
from pydantic_settings import BaseSettings
|
|
9
|
-
|
|
10
|
-
__all__ = ["publish_event", "declare_exchange"]
|
|
11
|
-
|
|
12
|
-
from event_sourcery.event_store import Event, Metadata, StreamId
|
|
13
|
-
from event_sourcery.event_store.event.registry import event_name
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class BrokerSettings(BaseSettings):
|
|
17
|
-
BROKER_URL: AmqpDsn = Field(default="amqp://guest:guest@rabbitmq//")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class PoolFactory:
|
|
21
|
-
_pool: ConnectionPool | None = None
|
|
22
|
-
_config: BrokerSettings | None = None
|
|
23
|
-
|
|
24
|
-
@classmethod
|
|
25
|
-
def configure(cls, broker_url: str) -> None:
|
|
26
|
-
if cls._pool is not None:
|
|
27
|
-
cls._pool.force_close_all()
|
|
28
|
-
cls._pool = None
|
|
29
|
-
|
|
30
|
-
cls._config = BrokerSettings(BROKER_URL=broker_url)
|
|
31
|
-
|
|
32
|
-
@classmethod
|
|
33
|
-
def get(cls) -> ConnectionPool:
|
|
34
|
-
if cls._config is None:
|
|
35
|
-
cls._config = BrokerSettings()
|
|
36
|
-
|
|
37
|
-
if cls._pool is None:
|
|
38
|
-
connection = Connection(
|
|
39
|
-
cls._config.BROKER_URL, transport_options={"confirm_publish": True}
|
|
40
|
-
)
|
|
41
|
-
cls._pool = connections[connection]
|
|
42
|
-
return cls._pool
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
EVENT_SOURCERY_EXCHANGE = "event_sourcery.events"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def publish_event(
|
|
49
|
-
metadata: Metadata, stream_name: str | None, stream_id: StreamId
|
|
50
|
-
) -> None:
|
|
51
|
-
name = event_name(type(metadata.event))
|
|
52
|
-
stream_category = stream_name.split(".", maxsplit=1)[0] if stream_name else None
|
|
53
|
-
_publish(
|
|
54
|
-
routing_key=name,
|
|
55
|
-
headers={
|
|
56
|
-
"event": name,
|
|
57
|
-
"stream_name": stream_name,
|
|
58
|
-
"stream_category": stream_category,
|
|
59
|
-
"stream_id": str(stream_id),
|
|
60
|
-
},
|
|
61
|
-
message=metadata.json(),
|
|
62
|
-
exchange=EVENT_SOURCERY_EXCHANGE,
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def declare_exchange() -> None:
|
|
67
|
-
_declare(Exchange(name=EVENT_SOURCERY_EXCHANGE, type="headers"))
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _publish(
|
|
71
|
-
routing_key: str,
|
|
72
|
-
message: str,
|
|
73
|
-
exchange: str,
|
|
74
|
-
headers: dict,
|
|
75
|
-
) -> None:
|
|
76
|
-
with PoolFactory.get().acquire(block=True) as conn:
|
|
77
|
-
producer = conn.Producer()
|
|
78
|
-
producer.publish(
|
|
79
|
-
message,
|
|
80
|
-
exchange=exchange,
|
|
81
|
-
routing_key=routing_key,
|
|
82
|
-
headers=headers,
|
|
83
|
-
content_type="application/json",
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _declare(queue_or_exchange: Exchange | Queue) -> None:
|
|
88
|
-
with PoolFactory.get().acquire(block=True) as conn:
|
|
89
|
-
queue_or_exchange(conn).declare()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class InvalidSubscription(Exception):
|
|
93
|
-
pass
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
@dataclass(frozen=True)
|
|
97
|
-
class Subscription:
|
|
98
|
-
event_types: list[Type[Event]] = field(default_factory=list)
|
|
99
|
-
stream_categories: list[str] = field(default_factory=list)
|
|
100
|
-
|
|
101
|
-
def __post_init__(self) -> None:
|
|
102
|
-
if len(self.event_types) == 0 and len(self.stream_categories) == 0:
|
|
103
|
-
raise InvalidSubscription(
|
|
104
|
-
"At least one event type or stream category needs to be specified"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def consume(
|
|
109
|
-
subscription: Subscription,
|
|
110
|
-
listener: Callable[[Metadata, StreamId], None],
|
|
111
|
-
) -> None:
|
|
112
|
-
queue = Queue(f"event_sourcery.{listener.__name__}", durable=True)
|
|
113
|
-
_declare(queue)
|
|
114
|
-
|
|
115
|
-
def callback(body: dict, message: Message) -> None:
|
|
116
|
-
event_name = message.headers["event"]
|
|
117
|
-
event_type = Event.__registry__.type_for_name(event_name)
|
|
118
|
-
|
|
119
|
-
stream_id = StreamId(
|
|
120
|
-
from_hex=message.headers["stream_id"],
|
|
121
|
-
name=message.headers["stream_name"],
|
|
122
|
-
)
|
|
123
|
-
metadata = Metadata[event_type](**body) # type: ignore
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
listener(metadata, stream_id)
|
|
127
|
-
except Exception: # TODO: DLQ, retries, error handling etc
|
|
128
|
-
message.reject()
|
|
129
|
-
raise
|
|
130
|
-
else:
|
|
131
|
-
message.ack()
|
|
132
|
-
|
|
133
|
-
with PoolFactory.get().acquire(block=True) as conn:
|
|
134
|
-
for event_type in subscription.event_types:
|
|
135
|
-
queue(conn).bind_to(
|
|
136
|
-
exchange=EVENT_SOURCERY_EXCHANGE,
|
|
137
|
-
arguments={
|
|
138
|
-
"event": event_name(event_type),
|
|
139
|
-
},
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
for stream_category in subscription.stream_categories:
|
|
143
|
-
queue(conn).bind_to(
|
|
144
|
-
exchange=EVENT_SOURCERY_EXCHANGE,
|
|
145
|
-
arguments={
|
|
146
|
-
"stream_category": stream_category,
|
|
147
|
-
},
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
with conn.Consumer([queue], callbacks=[callback]):
|
|
151
|
-
while True:
|
|
152
|
-
try:
|
|
153
|
-
conn.drain_events()
|
|
154
|
-
except KeyboardInterrupt:
|
|
155
|
-
return
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
from datetime import timedelta
|
|
2
|
-
from typing import Iterator
|
|
3
|
-
|
|
4
|
-
from event_sourcery.event_store import Position, RecordedRaw
|
|
5
|
-
from event_sourcery.event_store.interfaces import SubscriptionStrategy
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class SqlAlchemySubscriptionStrategy(SubscriptionStrategy):
|
|
9
|
-
def subscribe_to_all(
|
|
10
|
-
self,
|
|
11
|
-
start_from: Position,
|
|
12
|
-
batch_size: int,
|
|
13
|
-
timelimit: timedelta,
|
|
14
|
-
) -> Iterator[list[RecordedRaw]]:
|
|
15
|
-
raise NotImplementedError
|
|
16
|
-
|
|
17
|
-
def subscribe_to_category(
|
|
18
|
-
self,
|
|
19
|
-
start_from: Position,
|
|
20
|
-
batch_size: int,
|
|
21
|
-
timelimit: timedelta,
|
|
22
|
-
category: str,
|
|
23
|
-
) -> Iterator[list[RecordedRaw]]:
|
|
24
|
-
raise NotImplementedError
|
|
25
|
-
|
|
26
|
-
def subscribe_to_events(
|
|
27
|
-
self,
|
|
28
|
-
start_from: Position,
|
|
29
|
-
batch_size: int,
|
|
30
|
-
timelimit: timedelta,
|
|
31
|
-
events: list[str],
|
|
32
|
-
) -> Iterator[list[RecordedRaw]]:
|
|
33
|
-
raise NotImplementedError
|
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/aggregate/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery/event_store/outbox.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/event_store.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/outbox.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/py.typed
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/stream.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_esdb/subscription.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/guid.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/jsonb.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/models.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/outbox.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a0 → python_event_sourcery-0.4.0a1}/event_sourcery_sqlalchemy/py.typed
RENAMED
|
File without changes
|