eventsourcing 9.3.5__py3-none-any.whl → 9.4.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.

@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import threading
6
+ import weakref
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Iterator, Sequence
9
+ from threading import Event, Thread
10
+ from traceback import format_exc
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
12
+ from warnings import warn
13
+
14
+ from eventsourcing.application import Application, ProcessingEvent
15
+ from eventsourcing.dispatch import singledispatchmethod
16
+ from eventsourcing.domain import DomainEventProtocol
17
+ from eventsourcing.persistence import (
18
+ InfrastructureFactory,
19
+ IntegrityError,
20
+ ProcessRecorder,
21
+ Tracking,
22
+ TrackingRecorder,
23
+ TTrackingRecorder,
24
+ WaitInterruptedError,
25
+ )
26
+ from eventsourcing.utils import Environment, EnvType
27
+
28
+ if TYPE_CHECKING:
29
+ from types import TracebackType
30
+
31
+ from typing_extensions import Self
32
+
33
+
34
+ class ApplicationSubscription(Iterator[tuple[DomainEventProtocol, Tracking]]):
35
+ """An iterator that yields all domain events recorded in an application
36
+ sequence that have notification IDs greater than a given value. The iterator
37
+ will block when all recorded domain events have been yielded, and then
38
+ continue when new events are recorded. Domain events are returned along
39
+ with tracking objects that identify the position in the application sequence.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ app: Application,
45
+ gt: int | None = None,
46
+ topics: Sequence[str] = (),
47
+ ):
48
+ """
49
+ Starts a subscription to application's recorder.
50
+ """
51
+ self.name = app.name
52
+ self.recorder = app.recorder
53
+ self.mapper = app.mapper
54
+ self.subscription = self.recorder.subscribe(gt=gt, topics=topics)
55
+
56
+ def stop(self) -> None:
57
+ """Stops the subscription to the application's recorder."""
58
+ self.subscription.stop()
59
+
60
+ def __enter__(self) -> Self:
61
+ """Calls __enter__ on the stored event subscription."""
62
+ self.subscription.__enter__()
63
+ return self
64
+
65
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
66
+ """Calls __exit__ on the stored event subscription."""
67
+ self.subscription.__exit__(*args, **kwargs)
68
+
69
+ def __iter__(self) -> Self:
70
+ return self
71
+
72
+ def __next__(self) -> tuple[DomainEventProtocol, Tracking]:
73
+ """Returns the next stored event from subscription to the application's
74
+ recorder. Constructs a tracking object that identifies the position of
75
+ the event in the application sequence. Constructs a domain event object
76
+ from the stored event object using the application's mapper. Returns a
77
+ tuple of the domain event object and the tracking object.
78
+ """
79
+ notification = next(self.subscription)
80
+ tracking = Tracking(self.name, notification.id)
81
+ domain_event = self.mapper.to_domain_event(notification)
82
+ return domain_event, tracking
83
+
84
+ def __del__(self) -> None:
85
+ """Stops the stored event subscription."""
86
+ with contextlib.suppress(AttributeError):
87
+ self.stop()
88
+
89
+
90
+ class Projection(ABC, Generic[TTrackingRecorder]):
91
+ name: str = ""
92
+ """
93
+ Name of projection, used to pick prefixed environment
94
+ variables and define database table names.
95
+ """
96
+ topics: tuple[str, ...] = ()
97
+ """
98
+ Event topics, used to filter events in database when subscribing to an application.
99
+ """
100
+
101
+ def __init_subclass__(cls, **kwargs: Any) -> None:
102
+ if "name" not in cls.__dict__:
103
+ cls.name = cls.__name__
104
+
105
+ def __init__(
106
+ self,
107
+ view: TTrackingRecorder,
108
+ ):
109
+ """Initialises the view property with the given view argument."""
110
+ self._view = view
111
+
112
+ @property
113
+ def view(self) -> TTrackingRecorder:
114
+ """Materialised view of an event-sourced application."""
115
+ return self._view
116
+
117
+ @singledispatchmethod
118
+ @abstractmethod
119
+ def process_event(
120
+ self, domain_event: DomainEventProtocol, tracking: Tracking
121
+ ) -> None:
122
+ """Process a domain event and track it."""
123
+
124
+
125
+ class EventSourcedProjection(Application, ABC):
126
+ """Extends the :py:class:`~eventsourcing.application.Application` class
127
+ by using a process recorder as its application recorder, and by
128
+ processing domain events through its :py:func:`policy` method.
129
+ """
130
+
131
+ topics: ClassVar[Sequence[str]] = ()
132
+
133
+ def __init__(self, env: EnvType | None = None) -> None:
134
+ super().__init__(env)
135
+ self.recorder: ProcessRecorder
136
+ self.processing_lock = threading.Lock()
137
+
138
+ def construct_recorder(self) -> ProcessRecorder:
139
+ """Constructs and returns a :class:`~eventsourcing.persistence.ProcessRecorder`
140
+ for the application to use as its application recorder.
141
+ """
142
+ return self.factory.process_recorder()
143
+
144
+ def process_event(
145
+ self, domain_event: DomainEventProtocol, tracking: Tracking
146
+ ) -> None:
147
+ """Calls :func:`~eventsourcing.system.Follower.policy` method with
148
+ the given :class:`~eventsourcing.domain.AggregateEvent` and a
149
+ new :class:`~eventsourcing.application.ProcessingEvent` created from
150
+ the given :class:`~eventsourcing.persistence.Tracking` object.
151
+
152
+ The policy will collect any new aggregate events on the process
153
+ event object.
154
+
155
+ After the policy method returns, the process event object will
156
+ then be recorded by calling
157
+ :func:`~eventsourcing.application.Application.record`, which
158
+ will return new notifications.
159
+
160
+ After calling
161
+ :func:`~eventsourcing.application.Application.take_snapshots`,
162
+ the new notifications are passed to the
163
+ :func:`~eventsourcing.application.Application.notify` method.
164
+ """
165
+ processing_event = ProcessingEvent(tracking=tracking)
166
+ self.policy(domain_event, processing_event)
167
+ try:
168
+ recordings = self._record(processing_event)
169
+ except IntegrityError:
170
+ if self.recorder.has_tracking_id(
171
+ tracking.application_name,
172
+ tracking.notification_id,
173
+ ):
174
+ pass
175
+ else:
176
+ raise
177
+ else:
178
+ self._take_snapshots(processing_event)
179
+ self.notify(processing_event.events)
180
+ self._notify(recordings)
181
+
182
+ @singledispatchmethod
183
+ def policy(
184
+ self,
185
+ domain_event: DomainEventProtocol,
186
+ processing_event: ProcessingEvent,
187
+ ) -> None:
188
+ """Abstract domain event processing policy method. Must be
189
+ implemented by event processing applications. When
190
+ processing the given domain event, event processing
191
+ applications must use the :func:`~ProcessingEvent.collect_events`
192
+ method of the given :py:class:`~ProcessingEvent` object (not
193
+ the application's :func:`~eventsourcing.application.Application.save`
194
+ method) so that the new domain events will be recorded atomically
195
+ and uniquely with tracking information about the position of the processed
196
+ event in its application sequence.
197
+ """
198
+
199
+
200
+ TApplication = TypeVar("TApplication", bound=Application)
201
+ TEventSourcedProjection = TypeVar(
202
+ "TEventSourcedProjection", bound=EventSourcedProjection
203
+ )
204
+
205
+
206
+ class BaseProjectionRunner(Generic[TApplication]):
207
+ def __init__(
208
+ self,
209
+ *,
210
+ projection: EventSourcedProjection | Projection[Any],
211
+ application_class: type[TApplication],
212
+ tracking_recorder: TrackingRecorder,
213
+ topics: Sequence[str],
214
+ env: EnvType | None = None,
215
+ ) -> None:
216
+ self._projection = projection
217
+ self._is_interrupted = Event()
218
+ self._has_called_stop = False
219
+
220
+ # Construct the application.
221
+ self.app: TApplication = application_class(env)
222
+
223
+ self._tracking_recorder = tracking_recorder
224
+
225
+ # Subscribe to the application.
226
+ self._subscription = ApplicationSubscription(
227
+ app=self.app,
228
+ gt=self._tracking_recorder.max_tracking_id(self.app.name),
229
+ topics=topics,
230
+ )
231
+
232
+ # Start a thread to stop the subscription when the runner is interrupted.
233
+ self._thread_error: BaseException | None = None
234
+ self._stop_thread = Thread(
235
+ target=self._stop_subscription_when_stopping,
236
+ kwargs={
237
+ "subscription": self._subscription,
238
+ "is_stopping": self._is_interrupted,
239
+ },
240
+ )
241
+ self._stop_thread.start()
242
+
243
+ # Start a thread to iterate over the subscription.
244
+ self._processing_thread = Thread(
245
+ target=self._process_events_loop,
246
+ kwargs={
247
+ "subscription": self._subscription,
248
+ "projection": self._projection,
249
+ "is_stopping": self._is_interrupted,
250
+ "runner": weakref.ref(self),
251
+ },
252
+ )
253
+ self._processing_thread.start()
254
+
255
+ @property
256
+ def is_interrupted(self) -> Event:
257
+ return self._is_interrupted
258
+
259
+ @staticmethod
260
+ def _construct_env(name: str, env: EnvType | None = None) -> Environment:
261
+ """Constructs environment from which projection will be configured."""
262
+ _env: dict[str, str] = {}
263
+ _env.update(os.environ)
264
+ if env is not None:
265
+ _env.update(env)
266
+ return Environment(name, _env)
267
+
268
+ def stop(self) -> None:
269
+ """Sets the "interrupted" event."""
270
+ self._has_called_stop = True
271
+ self._is_interrupted.set()
272
+
273
+ @staticmethod
274
+ def _stop_subscription_when_stopping(
275
+ subscription: ApplicationSubscription,
276
+ is_stopping: Event,
277
+ ) -> None:
278
+ """Stops the application subscription, which
279
+ will stop the event-processing thread.
280
+ """
281
+ try:
282
+ is_stopping.wait()
283
+ finally:
284
+ is_stopping.set()
285
+ subscription.stop()
286
+
287
+ @staticmethod
288
+ def _process_events_loop(
289
+ subscription: ApplicationSubscription,
290
+ projection: EventSourcedProjection | Projection[Any],
291
+ is_stopping: Event,
292
+ runner: weakref.ReferenceType[ProjectionRunner[Application, TrackingRecorder]],
293
+ ) -> None:
294
+ """Iterates over the subscription and calls process_event()."""
295
+ try:
296
+ with subscription:
297
+ for domain_event, tracking in subscription:
298
+ projection.process_event(domain_event, tracking)
299
+ except BaseException as e:
300
+ _runner = runner() # get reference from weakref
301
+ if _runner is not None:
302
+ _runner._thread_error = e # noqa: SLF001
303
+ else:
304
+ msg = "ProjectionRunner was deleted before error could be assigned:\n"
305
+ msg += format_exc()
306
+ warn(
307
+ msg,
308
+ RuntimeWarning,
309
+ stacklevel=2,
310
+ )
311
+ finally:
312
+ is_stopping.set()
313
+
314
+ def run_forever(self, timeout: float | None = None) -> None:
315
+ """Blocks until timeout, or until the runner is stopped or errors. Re-raises
316
+ any error otherwise exits normally
317
+ """
318
+ if (
319
+ self._is_interrupted.wait(timeout=timeout)
320
+ and self._thread_error is not None
321
+ ):
322
+ error = self._thread_error
323
+ self._thread_error = None
324
+ raise error
325
+
326
+ def wait(self, notification_id: int | None, timeout: float = 1.0) -> None:
327
+ """Blocks until timeout, or until the materialised view has recorded a tracking
328
+ object that is greater than or equal to the given notification ID.
329
+ """
330
+ try:
331
+ self._tracking_recorder.wait(
332
+ application_name=self.app.name,
333
+ notification_id=notification_id,
334
+ timeout=timeout,
335
+ interrupt=self._is_interrupted,
336
+ )
337
+ except WaitInterruptedError:
338
+ if self._thread_error:
339
+ error = self._thread_error
340
+ self._thread_error = None
341
+ raise error from None
342
+ if self._has_called_stop:
343
+ return
344
+ raise
345
+
346
+ def __enter__(self) -> Self:
347
+ return self
348
+
349
+ def __exit__(
350
+ self,
351
+ exc_type: type[BaseException] | None,
352
+ exc_val: BaseException | None,
353
+ exc_tb: TracebackType | None,
354
+ ) -> None:
355
+ """Calls stop() and waits for the event-processing thread to exit."""
356
+ self.stop()
357
+ self._stop_thread.join()
358
+ self._processing_thread.join()
359
+ if self._thread_error:
360
+ error = self._thread_error
361
+ self._thread_error = None
362
+ raise error
363
+
364
+ def __del__(self) -> None:
365
+ """Calls stop()."""
366
+ with contextlib.suppress(AttributeError):
367
+ self.stop()
368
+
369
+
370
+ class ProjectionRunner(
371
+ BaseProjectionRunner[TApplication], Generic[TApplication, TTrackingRecorder]
372
+ ):
373
+ def __init__(
374
+ self,
375
+ *,
376
+ application_class: type[TApplication],
377
+ projection_class: type[Projection[TTrackingRecorder]],
378
+ view_class: type[TTrackingRecorder],
379
+ env: EnvType | None = None,
380
+ ):
381
+ """Constructs application from given application class with given environment.
382
+ Also constructs a materialised view from given class using an infrastructure
383
+ factory constructed with an environment named after the projection. Also
384
+ constructs a projection with the constructed materialised view object.
385
+ Starts a subscription to application and, in a separate event-processing
386
+ thread, calls projection's process_event() method for each event and tracking
387
+ object pair received from the subscription.
388
+ """
389
+ # Construct the materialised view using an infrastructure factory.
390
+ self.view = (
391
+ InfrastructureFactory[TTrackingRecorder]
392
+ .construct(env=self._construct_env(name=projection_class.name, env=env))
393
+ .tracking_recorder(view_class)
394
+ )
395
+
396
+ # Construct the projection using the materialised view.
397
+ self.projection = projection_class(view=self.view)
398
+
399
+ super().__init__(
400
+ projection=self.projection,
401
+ application_class=application_class,
402
+ tracking_recorder=self.view,
403
+ topics=self.projection.topics,
404
+ env=env,
405
+ )
406
+
407
+
408
+ class EventSourcedProjectionRunner(
409
+ BaseProjectionRunner[TApplication], Generic[TApplication, TEventSourcedProjection]
410
+ ):
411
+ def __init__(
412
+ self,
413
+ *,
414
+ application_class: type[TApplication],
415
+ projection_class: type[TEventSourcedProjection],
416
+ env: EnvType | None = None,
417
+ ):
418
+ self.projection: TEventSourcedProjection = projection_class(
419
+ env=self._construct_env(name=projection_class.name, env=env)
420
+ )
421
+
422
+ super().__init__(
423
+ projection=self.projection,
424
+ application_class=application_class,
425
+ tracking_recorder=self.projection.recorder,
426
+ topics=self.projection.topics,
427
+ env=env,
428
+ )