python-event-sourcery 0.4.0a13__tar.gz → 0.4.0a14__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 (53) hide show
  1. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/PKG-INFO +2 -1
  2. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/__init__.py +6 -5
  3. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/dto.py +5 -0
  4. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/stream_id.py +1 -1
  5. python_event_sourcery-0.4.0a14/event_sourcery_django/__init__.py +80 -0
  6. python_event_sourcery-0.4.0a14/event_sourcery_django/dto.py +83 -0
  7. python_event_sourcery-0.4.0a14/event_sourcery_django/event_store.py +151 -0
  8. python_event_sourcery-0.4.0a14/event_sourcery_django/migrations/0001_initial.py +95 -0
  9. python_event_sourcery-0.4.0a14/event_sourcery_django/models.py +86 -0
  10. python_event_sourcery-0.4.0a14/event_sourcery_django/outbox.py +51 -0
  11. python_event_sourcery-0.4.0a14/event_sourcery_django/subscription.py +164 -0
  12. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/event_store.py +8 -5
  13. python_event_sourcery-0.4.0a14/event_sourcery_esdb/py.typed +0 -0
  14. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/stream.py +1 -1
  15. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/models.py +2 -2
  16. python_event_sourcery-0.4.0a14/event_sourcery_sqlalchemy/py.typed +0 -0
  17. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/pyproject.toml +11 -1
  18. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/LICENSE +0 -0
  19. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/__init__.py +0 -0
  20. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/__init__.py +0 -0
  21. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/aggregate.py +0 -0
  22. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/repository.py +0 -0
  23. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/dispatcher.py +0 -0
  24. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/__init__.py +1 -1
  25. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/registry.py +0 -0
  26. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/serde.py +0 -0
  27. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event_store.py +0 -0
  28. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/exceptions.py +0 -0
  29. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/factory.py +0 -0
  30. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/in_memory.py +0 -0
  31. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/interfaces.py +0 -0
  32. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/outbox.py +0 -0
  33. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/subscription.py +0 -0
  34. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/tenant_id.py +0 -0
  35. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/versioning.py +0 -0
  36. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/py.typed +0 -0
  37. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/__init__.py +0 -0
  38. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/cursors_dao.py +0 -0
  39. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/projector.py +0 -0
  40. python_event_sourcery-0.4.0a13/event_sourcery_esdb/py.typed → python_event_sourcery-0.4.0a14/event_sourcery_django/migrations/__init__.py +0 -0
  41. {python_event_sourcery-0.4.0a13/event_sourcery_sqlalchemy → python_event_sourcery-0.4.0a14/event_sourcery_django}/py.typed +0 -0
  42. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/__init__.py +0 -0
  43. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/dto.py +0 -0
  44. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/outbox.py +0 -0
  45. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/subscription.py +0 -0
  46. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/__init__.py +2 -2
  47. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/cursors_dao.py +0 -0
  48. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/dto.py +0 -0
  49. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/event_store.py +0 -0
  50. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/guid.py +0 -0
  51. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/jsonb.py +0 -0
  52. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/outbox.py +0 -0
  53. {python_event_sourcery-0.4.0a13 → python_event_sourcery-0.4.0a14}/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.0a14
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,
@@ -92,5 +92,10 @@ class Entry:
92
92
 
93
93
  @dataclasses.dataclass(frozen=True)
94
94
  class Recorded(Entry):
95
+ """DTO containing events saved in the event store.
96
+
97
+ It contains position of the event in the event store and tenant id.
98
+ """
99
+
95
100
  position: Position
96
101
  tenant_id: TenantId = DEFAULT_TENANT
@@ -32,7 +32,7 @@ class StreamUUID(UUID):
32
32
  return uuid5(self.NAMESPACE, name)
33
33
 
34
34
  def __repr__(self) -> str:
35
- return f"{type(self).__name__}" f"(hex={self!s}, name={self.name})"
35
+ return f"{type(self).__name__}(hex={self!s}, name={self.name})"
36
36
 
37
37
 
38
38
  @dataclass(frozen=True, repr=False, eq=False)
@@ -0,0 +1,80 @@
1
+ __all__ = [
2
+ "Config",
3
+ "DjangoBackendFactory",
4
+ ]
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import timedelta
8
+ from typing import cast
9
+
10
+ from pydantic import BaseModel, ConfigDict, PositiveInt
11
+ from typing_extensions import Self
12
+
13
+ from event_sourcery import event_store as es
14
+ from event_sourcery.event_store import (
15
+ BackendFactory,
16
+ Dispatcher,
17
+ Event,
18
+ EventRegistry,
19
+ EventStore,
20
+ )
21
+ from event_sourcery.event_store.event import Serde
22
+ from event_sourcery.event_store.factory import (
23
+ NoOutboxStorageStrategy,
24
+ TransactionalBackend,
25
+ no_filter,
26
+ )
27
+ from event_sourcery.event_store.interfaces import (
28
+ OutboxFiltererStrategy,
29
+ OutboxStorageStrategy,
30
+ )
31
+ from event_sourcery.event_store.outbox import Outbox
32
+
33
+
34
+ class Config(BaseModel):
35
+ model_config = ConfigDict(extra="forbid", frozen=True)
36
+
37
+ outbox_attempts: PositiveInt = 3
38
+ gap_retry_interval: timedelta = timedelta(seconds=0.5)
39
+
40
+
41
+ @dataclass(repr=False)
42
+ class DjangoBackendFactory(BackendFactory):
43
+ _config: Config = field(default_factory=Config)
44
+ _serde: Serde = field(default_factory=lambda: Serde(Event.__registry__))
45
+ _outbox_strategy: OutboxStorageStrategy | None = None
46
+
47
+ def build(self) -> TransactionalBackend:
48
+ from event_sourcery_django.event_store import DjangoStorageStrategy
49
+ from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
50
+ from event_sourcery_django.subscription import DjangoSubscriptionStrategy
51
+
52
+ outbox = cast(DjangoOutboxStorageStrategy | None, self._outbox_strategy)
53
+ backend = TransactionalBackend()
54
+ backend.serde = self._serde
55
+ backend.in_transaction = Dispatcher(backend.serde)
56
+ storage_strategy = DjangoStorageStrategy(backend.in_transaction, outbox)
57
+ backend.event_store = EventStore(storage_strategy, backend.serde)
58
+ backend.outbox = Outbox(outbox or NoOutboxStorageStrategy(), backend.serde)
59
+ backend.subscriber = es.subscription.SubscriptionBuilder(
60
+ _serde=backend.serde,
61
+ _strategy=DjangoSubscriptionStrategy(self._config.gap_retry_interval),
62
+ )
63
+ return backend
64
+
65
+ def with_event_registry(self, event_registry: EventRegistry) -> Self:
66
+ self._serde = Serde(event_registry)
67
+ return self
68
+
69
+ def with_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
70
+ from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
71
+
72
+ self._outbox_strategy = DjangoOutboxStorageStrategy(
73
+ filterer,
74
+ self._config.outbox_attempts,
75
+ )
76
+ return self
77
+
78
+ def without_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
79
+ self._outbox_strategy = None
80
+ return self
@@ -0,0 +1,83 @@
1
+ from datetime import datetime, timezone
2
+ from uuid import UUID
3
+
4
+ from event_sourcery.event_store import RawEvent, RecordedRaw, StreamId
5
+ from event_sourcery_django.models import Event, OutboxEntry, Snapshot, Stream
6
+
7
+
8
+ def raw_event(from_entry: Event, in_stream: Stream) -> RawEvent:
9
+ return RawEvent(
10
+ uuid=from_entry.uuid,
11
+ stream_id=StreamId(
12
+ uuid=in_stream.uuid,
13
+ name=in_stream.name,
14
+ category=None if in_stream.category == "" else in_stream.category,
15
+ ),
16
+ created_at=from_entry.created_at,
17
+ version=from_entry.version,
18
+ name=from_entry.name,
19
+ data=from_entry.data,
20
+ context=from_entry.event_context,
21
+ )
22
+
23
+
24
+ def entry(from_raw: RawEvent, to_stream: Stream) -> Event:
25
+ return Event(
26
+ uuid=from_raw.uuid,
27
+ created_at=from_raw.created_at,
28
+ name=from_raw.name,
29
+ data=from_raw.data,
30
+ event_context=from_raw.context,
31
+ version=from_raw.version,
32
+ stream=to_stream,
33
+ )
34
+
35
+
36
+ def snapshot(from_raw: RawEvent, to_stream: Stream) -> Snapshot:
37
+ return Snapshot(
38
+ uuid=from_raw.uuid,
39
+ created_at=from_raw.created_at,
40
+ name=from_raw.name,
41
+ data=from_raw.data,
42
+ event_context=from_raw.context,
43
+ version=from_raw.version,
44
+ stream=to_stream,
45
+ )
46
+
47
+
48
+ def outbox_entry(from_raw: RecordedRaw, max_attempts: int) -> OutboxEntry:
49
+ return OutboxEntry(
50
+ created_at=datetime.now(timezone.utc).replace(tzinfo=None),
51
+ data={
52
+ "created_at": from_raw.entry.created_at.isoformat(),
53
+ "uuid": str(from_raw.entry.uuid),
54
+ "stream_id": str(from_raw.entry.stream_id),
55
+ "version": from_raw.entry.version,
56
+ "name": from_raw.entry.name,
57
+ "data": from_raw.entry.data,
58
+ "context": from_raw.entry.context,
59
+ "tenant_id": from_raw.tenant_id,
60
+ },
61
+ stream_name=from_raw.entry.stream_id.name,
62
+ position=from_raw.position,
63
+ tries_left=max_attempts,
64
+ )
65
+
66
+
67
+ def raw_outbox(from_entry: OutboxEntry) -> RecordedRaw:
68
+ return RecordedRaw(
69
+ entry=RawEvent(
70
+ uuid=UUID(from_entry.data["uuid"]),
71
+ stream_id=StreamId(
72
+ from_hex=from_entry.data["stream_id"],
73
+ name=from_entry.stream_name,
74
+ ),
75
+ created_at=datetime.fromisoformat(from_entry.data["created_at"]),
76
+ version=from_entry.data["version"],
77
+ name=from_entry.data["name"],
78
+ data=from_entry.data["data"],
79
+ context=from_entry.data["context"],
80
+ ),
81
+ position=from_entry.position,
82
+ tenant_id=from_entry.data["tenant_id"],
83
+ )
@@ -0,0 +1,151 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, replace
3
+ from typing import cast
4
+
5
+ from more_itertools import first, first_true
6
+ from typing_extensions import Self
7
+
8
+ from event_sourcery.event_store import (
9
+ NO_VERSIONING,
10
+ Dispatcher,
11
+ Position,
12
+ RawEvent,
13
+ RecordedRaw,
14
+ StreamId,
15
+ TenantId,
16
+ Versioning,
17
+ )
18
+ from event_sourcery.event_store.exceptions import (
19
+ AnotherStreamWithThisNameButOtherIdExists,
20
+ ConcurrentStreamWriteError,
21
+ )
22
+ from event_sourcery.event_store.interfaces import StorageStrategy
23
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT
24
+ from event_sourcery_django import dto, models
25
+ from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
26
+
27
+
28
+ @dataclass(repr=True)
29
+ class DjangoStorageStrategy(StorageStrategy):
30
+ _dispatcher: Dispatcher
31
+ _outbox: DjangoOutboxStorageStrategy | None = None
32
+ _tenant_id: TenantId = DEFAULT_TENANT
33
+
34
+ def fetch_events(
35
+ self,
36
+ stream_id: StreamId,
37
+ start: int | None = None,
38
+ stop: int | None = None,
39
+ ) -> list[RawEvent]:
40
+ try:
41
+ stream = models.Stream.objects.by_stream_id(
42
+ stream_id=stream_id,
43
+ tenant_id=self._tenant_id,
44
+ ).get()
45
+ except models.Stream.DoesNotExist:
46
+ return []
47
+
48
+ events_query = models.Event.objects.filter(stream=stream).order_by("version")
49
+
50
+ if start is not None:
51
+ events_query = events_query.filter(version__gte=start)
52
+
53
+ if stop is not None:
54
+ events_query = events_query.filter(version__lt=stop)
55
+
56
+ events: Sequence[models.Event | models.Snapshot]
57
+
58
+ snapshot_query = models.Snapshot.objects.filter(stream=stream).order_by(
59
+ "-created_at"
60
+ )
61
+ if start is not None:
62
+ snapshot_query = snapshot_query.filter(version__gte=start)
63
+ if stop is not None:
64
+ snapshot_query = snapshot_query.filter(version__lt=stop)
65
+ latest_snapshot = snapshot_query.first()
66
+ if latest_snapshot is None:
67
+ events = events_query.all()
68
+ else:
69
+ newer_events = events_query.filter(
70
+ version__gt=latest_snapshot.version
71
+ ).all()
72
+ events = [latest_snapshot, *list(newer_events)]
73
+
74
+ return [dto.raw_event(event, stream) for event in events]
75
+
76
+ def insert_events(
77
+ self,
78
+ stream_id: StreamId,
79
+ versioning: Versioning,
80
+ events: list[RawEvent],
81
+ ) -> None:
82
+ self._ensure_stream(stream_id=stream_id, versioning=versioning)
83
+ event = cast(RawEvent, first(events))
84
+ stream = models.Stream.objects.by_stream_id(
85
+ stream_id=event.stream_id,
86
+ tenant_id=self._tenant_id,
87
+ ).get()
88
+ entries = [dto.entry(event, stream) for event in events]
89
+ models.Event.objects.bulk_create(entries)
90
+ records = [
91
+ RecordedRaw(entry=raw, position=db.id, tenant_id=self._tenant_id)
92
+ for raw, db in zip(events, entries, strict=False)
93
+ ]
94
+ if self._outbox:
95
+ self._outbox.put_into_outbox(records)
96
+ self._dispatcher.dispatch(*records)
97
+
98
+ def _ensure_stream(self, stream_id: StreamId, versioning: Versioning) -> None:
99
+ initial_version = versioning.initial_version
100
+
101
+ matching_streams = models.Stream.objects.by_stream_id(
102
+ stream_id=stream_id,
103
+ tenant_id=self._tenant_id,
104
+ ).all()
105
+ if stream_id.name and matching_streams:
106
+ stream_with_same_name = first_true(
107
+ matching_streams, pred=lambda stream: stream.name == stream_id.name
108
+ )
109
+ if (
110
+ stream_with_same_name is not None
111
+ and stream_with_same_name.uuid != stream_id
112
+ ):
113
+ raise AnotherStreamWithThisNameButOtherIdExists()
114
+
115
+ model, created = models.Stream.objects.get_or_create(
116
+ uuid=stream_id,
117
+ name=stream_id.name,
118
+ category=stream_id.category or "",
119
+ tenant_id=self._tenant_id,
120
+ defaults={"version": initial_version},
121
+ )
122
+
123
+ versioning.validate_if_compatible(model.version)
124
+
125
+ if versioning.expected_version and versioning is not NO_VERSIONING:
126
+ result = models.Stream.objects.filter(
127
+ id=model.id, version=versioning.expected_version
128
+ ).update(version=versioning.initial_version)
129
+ if result != 1:
130
+ raise ConcurrentStreamWriteError
131
+
132
+ def save_snapshot(self, snapshot: RawEvent) -> None:
133
+ stream = models.Stream.objects.by_stream_id(
134
+ stream_id=snapshot.stream_id,
135
+ tenant_id=self._tenant_id,
136
+ ).get()
137
+ entry = dto.snapshot(from_raw=snapshot, to_stream=stream)
138
+ entry.save()
139
+
140
+ def delete_stream(self, stream_id: StreamId) -> None:
141
+ models.Stream.objects.by_stream_id(
142
+ stream_id=stream_id, tenant_id=self._tenant_id
143
+ ).delete()
144
+
145
+ @property
146
+ def current_position(self) -> Position | None:
147
+ last_event = models.Event.objects.last()
148
+ return last_event.id if last_event else Position(0)
149
+
150
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
151
+ return replace(self, _tenant_id=tenant_id)
@@ -0,0 +1,95 @@
1
+ # Generated by Django 5.0.4 on 2024-04-17 19:26
2
+
3
+ import uuid
4
+
5
+ import django.db.models.deletion
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+ initial = True
11
+
12
+ dependencies: list = []
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="OutboxEntry",
17
+ fields=[
18
+ ("id", models.BigAutoField(primary_key=True, serialize=False)),
19
+ ("created_at", models.DateTimeField()),
20
+ ("data", models.JSONField()),
21
+ (
22
+ "stream_name",
23
+ models.CharField(blank=True, max_length=255, null=True),
24
+ ),
25
+ ("position", models.IntegerField()),
26
+ ("tries_left", models.IntegerField(default=3)),
27
+ ],
28
+ ),
29
+ migrations.CreateModel(
30
+ name="Stream",
31
+ fields=[
32
+ ("id", models.BigAutoField(primary_key=True, serialize=False)),
33
+ ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
34
+ ("name", models.CharField(blank=True, max_length=255, null=True)),
35
+ ("category", models.CharField(default="", max_length=255)),
36
+ ("tenant_id", models.CharField(max_length=255)),
37
+ ("version", models.BigIntegerField(blank=True, null=True)),
38
+ ],
39
+ options={
40
+ "unique_together": {
41
+ ("name", "category", "tenant_id"),
42
+ ("uuid", "category", "tenant_id"),
43
+ },
44
+ },
45
+ ),
46
+ migrations.CreateModel(
47
+ name="Snapshot",
48
+ fields=[
49
+ ("uuid", models.UUIDField(primary_key=True, serialize=False)),
50
+ ("version", models.IntegerField()),
51
+ ("name", models.CharField(max_length=50)),
52
+ ("data", models.JSONField()),
53
+ ("event_context", models.JSONField()),
54
+ ("created_at", models.DateTimeField()),
55
+ (
56
+ "stream",
57
+ models.ForeignKey(
58
+ on_delete=django.db.models.deletion.CASCADE,
59
+ related_name="snapshots",
60
+ to="event_sourcery_django.stream",
61
+ ),
62
+ ),
63
+ ],
64
+ ),
65
+ migrations.CreateModel(
66
+ name="Event",
67
+ fields=[
68
+ ("id", models.BigAutoField(primary_key=True, serialize=False)),
69
+ ("version", models.IntegerField(blank=True, null=True)),
70
+ (
71
+ "uuid",
72
+ models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
73
+ ),
74
+ ("name", models.CharField(max_length=200)),
75
+ ("data", models.JSONField()),
76
+ ("event_context", models.JSONField()),
77
+ ("created_at", models.DateTimeField()),
78
+ (
79
+ "stream",
80
+ models.ForeignKey(
81
+ on_delete=django.db.models.deletion.CASCADE,
82
+ related_name="events",
83
+ to="event_sourcery_django.stream",
84
+ ),
85
+ ),
86
+ ],
87
+ options={
88
+ "indexes": [
89
+ models.Index(
90
+ fields=["stream", "version"], name="ix_events_stream_id_version"
91
+ )
92
+ ],
93
+ },
94
+ ),
95
+ ]
@@ -0,0 +1,86 @@
1
+ from typing import ClassVar
2
+ from uuid import uuid4
3
+
4
+ from django.core.exceptions import ObjectDoesNotExist
5
+ from django.db import models
6
+
7
+ from event_sourcery.event_store import StreamId, TenantId
8
+
9
+
10
+ class StreamManager(models.Manager):
11
+ def by_stream_id(self, stream_id: StreamId, tenant_id: TenantId) -> models.QuerySet:
12
+ category = stream_id.category or ""
13
+ condition = models.Q(uuid=stream_id, category=category, tenant_id=tenant_id)
14
+ if stream_id.name:
15
+ condition = condition | models.Q(
16
+ name=stream_id.name,
17
+ category=category,
18
+ tenant_id=tenant_id,
19
+ )
20
+ return self.filter(condition)
21
+
22
+
23
+ class Stream(models.Model):
24
+ DoesNotExist: ClassVar[ObjectDoesNotExist]
25
+
26
+ id = models.BigAutoField(primary_key=True)
27
+ uuid = models.UUIDField(default=uuid4, editable=False)
28
+ name = models.CharField(max_length=255, null=True, blank=True)
29
+ category = models.CharField(max_length=255, default="")
30
+ tenant_id = models.CharField(max_length=255)
31
+ version = models.BigIntegerField(null=True, blank=True)
32
+
33
+ objects = StreamManager()
34
+
35
+ class Meta:
36
+ unique_together = (
37
+ ("uuid", "category", "tenant_id"),
38
+ ("name", "category", "tenant_id"),
39
+ )
40
+
41
+
42
+ class Event(models.Model):
43
+ objects: models.Manager
44
+
45
+ id = models.BigAutoField(primary_key=True)
46
+ version = models.IntegerField(null=True, blank=True)
47
+ uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
48
+ name = models.CharField(max_length=200)
49
+ data = models.JSONField()
50
+ event_context = models.JSONField()
51
+ created_at = models.DateTimeField()
52
+ stream = models.ForeignKey(Stream, related_name="events", on_delete=models.CASCADE)
53
+
54
+ class Meta:
55
+ indexes = [
56
+ models.Index(
57
+ fields=["stream", "version"], name="ix_events_stream_id_version"
58
+ ),
59
+ ]
60
+
61
+
62
+ class Snapshot(models.Model):
63
+ objects: models.Manager
64
+
65
+ uuid = models.UUIDField(primary_key=True)
66
+ version = models.IntegerField()
67
+ stream = models.ForeignKey(
68
+ Stream,
69
+ related_name="snapshots",
70
+ on_delete=models.CASCADE,
71
+ )
72
+ name = models.CharField(max_length=50)
73
+ data = models.JSONField()
74
+ event_context = models.JSONField()
75
+ created_at = models.DateTimeField()
76
+
77
+
78
+ class OutboxEntry(models.Model):
79
+ objects: models.Manager
80
+
81
+ id = models.BigAutoField(primary_key=True)
82
+ created_at = models.DateTimeField()
83
+ data = models.JSONField()
84
+ stream_name = models.CharField(max_length=255, null=True, blank=True)
85
+ position = models.BigIntegerField()
86
+ tries_left = models.IntegerField()
@@ -0,0 +1,51 @@
1
+ import logging
2
+ from collections.abc import Iterator
3
+ from contextlib import AbstractContextManager, contextmanager
4
+ from dataclasses import dataclass
5
+
6
+ from event_sourcery.event_store import RecordedRaw
7
+ from event_sourcery.event_store.interfaces import (
8
+ OutboxFiltererStrategy,
9
+ OutboxStorageStrategy,
10
+ )
11
+ from event_sourcery_django import dto
12
+ from event_sourcery_django.models import OutboxEntry
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(repr=False)
18
+ class DjangoOutboxStorageStrategy(OutboxStorageStrategy):
19
+ _filterer: OutboxFiltererStrategy
20
+ _max_publish_attempts: int
21
+
22
+ def put_into_outbox(self, records: list[RecordedRaw]) -> None:
23
+ OutboxEntry.objects.bulk_create(
24
+ dto.outbox_entry(record, self._max_publish_attempts)
25
+ for record in records
26
+ if self._filterer(record.entry)
27
+ )
28
+
29
+ def outbox_entries(
30
+ self, limit: int
31
+ ) -> Iterator[AbstractContextManager[RecordedRaw]]:
32
+ entries = (
33
+ OutboxEntry.objects.select_for_update(skip_locked=True)
34
+ .filter(tries_left__gt=0)
35
+ .order_by("id")[:limit]
36
+ )
37
+
38
+ for entry in entries:
39
+ yield self._publish_context(entry)
40
+
41
+ @contextmanager
42
+ def _publish_context(self, entry: OutboxEntry) -> Iterator[RecordedRaw]:
43
+ raw = dto.raw_outbox(entry)
44
+ try:
45
+ yield raw
46
+ except Exception:
47
+ logger.exception("Failed to publish message #%d", entry.id)
48
+ entry.tries_left -= 1
49
+ entry.save()
50
+ else:
51
+ entry.delete()
@@ -0,0 +1,164 @@
1
+ import time
2
+ from collections.abc import Iterator
3
+ from dataclasses import dataclass
4
+ from datetime import timedelta
5
+ from typing import Protocol, cast
6
+
7
+ from event_sourcery.event_store import Position, RecordedRaw
8
+ from event_sourcery.event_store.interfaces import SubscriptionStrategy
9
+ from event_sourcery_django import dto, models
10
+
11
+
12
+ class DjangoSubscriptionStrategy(SubscriptionStrategy):
13
+ def __init__(self, gap_retry_interval: timedelta) -> None:
14
+ self._gap_retry_interval = gap_retry_interval
15
+
16
+ def subscribe_to_all(
17
+ self,
18
+ start_from: Position,
19
+ batch_size: int,
20
+ timelimit: timedelta,
21
+ ) -> Iterator[list[RecordedRaw]]:
22
+ return GapDetectingIterator(
23
+ get_batch=GetBatchToAll(batch_size),
24
+ gap_retry_interval=self._gap_retry_interval,
25
+ start_from=start_from,
26
+ batch_size=batch_size,
27
+ timelimit=timelimit,
28
+ )
29
+
30
+ def subscribe_to_category(
31
+ self,
32
+ start_from: Position,
33
+ batch_size: int,
34
+ timelimit: timedelta,
35
+ category: str,
36
+ ) -> Iterator[list[RecordedRaw]]:
37
+ return GapDetectingIterator(
38
+ get_batch=GetBatchToCategory(batch_size, category),
39
+ gap_retry_interval=self._gap_retry_interval,
40
+ start_from=start_from,
41
+ batch_size=batch_size,
42
+ timelimit=timelimit,
43
+ )
44
+
45
+ def subscribe_to_events(
46
+ self,
47
+ start_from: Position,
48
+ batch_size: int,
49
+ timelimit: timedelta,
50
+ events: list[str],
51
+ ) -> Iterator[list[RecordedRaw]]:
52
+ return GapDetectingIterator(
53
+ get_batch=GetBatchToEvents(batch_size, events),
54
+ gap_retry_interval=self._gap_retry_interval,
55
+ start_from=start_from,
56
+ batch_size=batch_size,
57
+ timelimit=timelimit,
58
+ )
59
+
60
+
61
+ class GetBatch(Protocol):
62
+ def __call__(self, position: Position) -> list[models.Event]: ...
63
+
64
+
65
+ class GetBatchToAll(GetBatch):
66
+ def __init__(self, batch_size: int) -> None:
67
+ self._batch_size = batch_size
68
+
69
+ def __call__(self, position: Position) -> list[models.Event]:
70
+ query = (
71
+ models.Event.objects.filter(id__gt=position)
72
+ .select_related("stream")
73
+ .order_by("id")
74
+ )
75
+
76
+ return list(query[: self._batch_size])
77
+
78
+
79
+ class GetBatchToCategory(GetBatch):
80
+ def __init__(self, batch_size: int, category: str) -> None:
81
+ self._batch_size = batch_size
82
+ self._category = category
83
+
84
+ def __call__(self, position: Position) -> list[models.Event]:
85
+ query = (
86
+ models.Event.objects.filter(
87
+ id__gt=position, stream__category=self._category
88
+ )
89
+ .select_related("stream")
90
+ .order_by("id")
91
+ )
92
+
93
+ return list(query[: self._batch_size])
94
+
95
+
96
+ class GetBatchToEvents(GetBatch):
97
+ def __init__(self, batch_size: int, events: list[str]) -> None:
98
+ self._batch_size = batch_size
99
+ self._events = events
100
+
101
+ def __call__(self, position: Position) -> list[models.Event]:
102
+ query = (
103
+ models.Event.objects.filter(id__gt=position, name__in=self._events)
104
+ .select_related("stream")
105
+ .order_by("id")
106
+ )
107
+
108
+ return list(query[: self._batch_size])
109
+
110
+
111
+ @dataclass
112
+ class Cursor:
113
+ position: Position
114
+
115
+ def advance(self, batch: list[models.Event]) -> None:
116
+ if len(batch) > 0:
117
+ self.position = batch[-1].id
118
+
119
+
120
+ class GapDetectingIterator(Iterator[list[RecordedRaw]]):
121
+ def __init__(
122
+ self,
123
+ get_batch: GetBatch,
124
+ gap_retry_interval: timedelta,
125
+ start_from: Position,
126
+ batch_size: int,
127
+ timelimit: timedelta,
128
+ ) -> None:
129
+ self._get_batch = get_batch
130
+ self._gap_retry_interval = gap_retry_interval
131
+ self._cursor = Cursor(position=start_from)
132
+ self._batch_size = batch_size
133
+ self._timelimit = timelimit
134
+
135
+ def __next__(self) -> list[RecordedRaw]:
136
+ start = time.monotonic()
137
+ while True:
138
+ batch = self._get_batch(self._cursor.position)
139
+ if self._is_continuous(batch) and len(batch) == self._batch_size:
140
+ self._cursor.advance(batch)
141
+ return self._batch_to_recorded_raw(batch)
142
+ elif time.monotonic() - start > self._timelimit.total_seconds():
143
+ self._cursor.advance(batch)
144
+ return self._batch_to_recorded_raw(batch)
145
+ else:
146
+ time.sleep(self._gap_retry_interval.total_seconds())
147
+
148
+ @staticmethod
149
+ def _is_continuous(batch: list[models.Event]) -> bool:
150
+ if len(batch) < 2:
151
+ return False
152
+
153
+ return cast(bool, batch[-1].id - batch[0].id + 1 == len(batch))
154
+
155
+ @staticmethod
156
+ def _batch_to_recorded_raw(batch: list[models.Event]) -> list[RecordedRaw]:
157
+ return [
158
+ RecordedRaw(
159
+ entry=dto.raw_event(event, event.stream),
160
+ position=event.id,
161
+ tenant_id=event.stream.tenant_id,
162
+ )
163
+ for event in batch
164
+ ]
@@ -74,11 +74,14 @@ class ESDBStorageStrategy(StorageStrategy):
74
74
  self._append_events(stream_name, events=stream_events)
75
75
 
76
76
  def _append_events(self, name: stream.Name, events: list[RawEvent]) -> int:
77
- return self._client.append_events(
78
- str(name),
79
- current_version=StreamState.ANY,
80
- events=(dto.new_entry(e) for e in events),
81
- timeout=self._timeout,
77
+ return cast(
78
+ int,
79
+ self._client.append_events(
80
+ str(name),
81
+ current_version=StreamState.ANY,
82
+ events=(dto.new_entry(e) for e in events),
83
+ timeout=self._timeout,
84
+ ),
82
85
  )
83
86
 
84
87
  def save_snapshot(self, snapshot: RawEvent) -> None:
@@ -56,5 +56,5 @@ def scope(
56
56
  start = cast(
57
57
  Position | None, start_version and Position.from_version(start_version)
58
58
  )
59
- stop = stop_version and Position.from_version(stop_version) or MAX_POSITION
59
+ stop = (stop_version and Position.from_version(stop_version)) or MAX_POSITION
60
60
  return start, stop - (start or 0)
@@ -45,14 +45,14 @@ class StreamIdComparator(Comparator[StreamId]):
45
45
  ) -> None:
46
46
  super().__init__(Composite(uuid, name, category))
47
47
 
48
- def __eq__(self, other: Any) -> ColumnElement[bool]: # type: ignore[override]
48
+ def __eq__(self, other: Any) -> bool: # type: ignore
49
49
  uuid, name, category = cast(Composite, self.__clause_element__()).attrs
50
50
  same_uuid = cast(ColumnElement[bool], uuid == other)
51
51
  same_name = cast(
52
52
  ColumnElement[bool], name == other.name if other.name else true()
53
53
  )
54
54
  same_category = cast(ColumnElement[bool], category == (other.category or ""))
55
- return and_(same_uuid, same_name, same_category)
55
+ return and_(same_uuid, same_name, same_category) # type: ignore
56
56
 
57
57
 
58
58
  class Stream:
@@ -3,12 +3,13 @@ name = "python-event-sourcery"
3
3
  requires-python = ">=3.10"
4
4
 
5
5
  [tool.poetry]
6
- version = "0.4.0a13"
6
+ version = "0.4.0a14"
7
7
  description = ""
8
8
  authors = ["Your Name <you@example.com>"]
9
9
  license = "MIT"
10
10
  packages = [
11
11
  { include = "event_sourcery" },
12
+ { include = "event_sourcery_django" },
12
13
  { include = "event_sourcery_esdb" },
13
14
  { include = "event_sourcery_sqlalchemy" },
14
15
  ]
@@ -20,6 +21,7 @@ more-itertools = "*"
20
21
  SQLAlchemy = {version = ">=2.0", optional = true}
21
22
  esdbclient = {version = ">=1.0b3", optional = true}
22
23
  django = {version = ">=4,<6", optional = true}
24
+ psycopg2-binary = ">2,<3"
23
25
 
24
26
  [tool.poetry.group.dev.dependencies]
25
27
  ruff = "*"
@@ -30,6 +32,9 @@ pytest-django = {version = "*"}
30
32
  mkdocs-material = "^8.3.9"
31
33
  psycopg2-binary = "^2.9.3"
32
34
  mkdocstrings = {version = "*", extras = ["python"]}
35
+ pymdown-extensions = "*"
36
+ time-machine = "*"
37
+ pika = "*"
33
38
 
34
39
  [tool.poetry.extras]
35
40
  sqlalchemy = ["SQLAlchemy"]
@@ -116,6 +121,11 @@ ignore = [
116
121
  "event_sourcery_django/models.py" = [
117
122
  "RUF012", # Mutable class attributes should be annotated with typing.ClassVar
118
123
  ]
124
+ "docs/documentation/code/*" = [
125
+ "S101", # Use of assert detected
126
+ "N806", # Variables in function should be lowercase
127
+ "B018", # Useless expressions
128
+ ]
119
129
 
120
130
  [tool.ruff.lint.pylint]
121
131
  max-args = 10
@@ -3,12 +3,12 @@ __all__ = [
3
3
  "Entry",
4
4
  "Event",
5
5
  "EventRegistry",
6
- "WrappedEvent",
7
6
  "Position",
8
7
  "RawEvent",
9
8
  "Recorded",
10
9
  "RecordedRaw",
11
10
  "Serde",
11
+ "WrappedEvent",
12
12
  ]
13
13
 
14
14
 
@@ -1,9 +1,9 @@
1
1
  __all__ = [
2
2
  "Config",
3
+ "SQLAlchemyBackendFactory",
4
+ "SqlAlchemyStorageStrategy",
3
5
  "configure_models",
4
6
  "models",
5
- "SqlAlchemyStorageStrategy",
6
- "SQLAlchemyBackendFactory",
7
7
  ]
8
8
 
9
9
  from dataclasses import dataclass, field