eventsourcing 9.5.0b3__py3-none-any.whl

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