flowevent 1.2__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,640 @@
1
+ """Asyncio-native event bus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import deque
7
+ from collections.abc import Awaitable, Callable
8
+ from concurrent.futures import Future
9
+ from dataclasses import dataclass, field as dataclass_field
10
+ from typing import Generic, TypeVar, cast
11
+
12
+ from .actions import AsyncEventBusActions, EventErrorContext, EventTimeoutContext
13
+ from .base import (
14
+ BaseEventBus,
15
+ normalize_replay_capacity,
16
+ validate_nonnegative_count,
17
+ validate_nonnegative_timeout,
18
+ validate_positive_capacity,
19
+ validate_positive_timeout,
20
+ )
21
+ from .contracts import DeliveryMode, ErrorPolicy, EventBusStats, PublishResult
22
+ from .compatibility import ExceptionGroup
23
+ from .errors import EventBusError, InvalidEventHandlerError
24
+ from .filtering import EventFilter, EventType, coerce_event_filter
25
+ from .history import EventHistory
26
+ from ._lifecycle import finish_cleanup
27
+ from .subscription import AsyncSubscription
28
+
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class _AsyncSubscriber(Generic[T]):
35
+ callback: Callable[[T], Awaitable[None]]
36
+ event_filter: EventFilter[T]
37
+ remaining: int | None
38
+ subscription: AsyncSubscription
39
+ replaying: bool = False
40
+ pending: deque[T] = dataclass_field(default_factory=deque)
41
+
42
+ def claim(self) -> tuple[bool, bool]:
43
+ """Reserve one delivery and report whether it is the final one."""
44
+
45
+ if not self.subscription.active:
46
+ return False, False
47
+ if self.remaining is None:
48
+ return True, False
49
+ if self.remaining <= 0:
50
+ return False, False
51
+ self.remaining -= 1
52
+ return True, self.remaining == 0
53
+
54
+ def buffer_if_replaying(self, event: T) -> bool:
55
+ if not self.replaying:
56
+ return False
57
+ if self.pending.maxlen is not None and len(self.pending) >= self.pending.maxlen:
58
+ raise BufferError("Event replay buffer is full")
59
+ self.pending.append(event)
60
+ return True
61
+
62
+ def finish_replay(self) -> tuple[T, ...] | None:
63
+ if self.pending:
64
+ events = tuple(self.pending)
65
+ self.pending.clear()
66
+ return events
67
+ self.replaying = False
68
+ return None
69
+
70
+
71
+ class AsyncEventBus(BaseEventBus[T]):
72
+ """Publish events to async handlers on one owning event loop."""
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ history: EventHistory[T] | int | None = None,
78
+ error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
79
+ delivery_mode: DeliveryMode = DeliveryMode.SEQUENTIAL,
80
+ max_concurrency: int | None = None,
81
+ replay_buffer_limit: int = 1_000,
82
+ max_schedules: int = 64,
83
+ actions: AsyncEventBusActions[T] | None = None,
84
+ on_before: Callable[[T], Awaitable[None]] | None = None,
85
+ on_after: Callable[[T, PublishResult], Awaitable[None]] | None = None,
86
+ on_error: Callable[[EventErrorContext[T]], Awaitable[None]] | None = None,
87
+ on_timeout: Callable[[EventTimeoutContext[T]], Awaitable[None]] | None = None,
88
+ ) -> None:
89
+ super().__init__(history=history, error_policy=error_policy)
90
+ replay_buffer_limit = normalize_replay_capacity(replay_buffer_limit)
91
+ max_schedules = validate_positive_capacity(
92
+ max_schedules,
93
+ name="Maximum periodic schedules",
94
+ )
95
+ direct_actions = (on_before, on_after, on_error, on_timeout)
96
+ if actions is not None and any(action is not None for action in direct_actions):
97
+ raise ValueError("Use actions or direct on_* callbacks, not both")
98
+ self._actions = actions or AsyncEventBusActions(
99
+ on_before=on_before,
100
+ on_after=on_after,
101
+ on_error=on_error,
102
+ on_timeout=on_timeout,
103
+ )
104
+ for action in (
105
+ self._actions.on_before,
106
+ self._actions.on_after,
107
+ self._actions.on_error,
108
+ self._actions.on_timeout,
109
+ ):
110
+ if action is not None and not self._is_async_handler(action):
111
+ raise InvalidEventHandlerError("AsyncEventBus actions must be async")
112
+ self._delivery_mode = DeliveryMode(delivery_mode)
113
+ self._max_concurrency = (
114
+ None
115
+ if max_concurrency is None
116
+ else validate_positive_capacity(
117
+ max_concurrency,
118
+ name="Maximum concurrent deliveries",
119
+ )
120
+ )
121
+ if self._max_concurrency is not None and self._delivery_mode is not DeliveryMode.CONCURRENT:
122
+ raise ValueError("max_concurrency requires DeliveryMode.CONCURRENT")
123
+ self._replay_buffer_limit = replay_buffer_limit
124
+ self._max_schedules = max_schedules
125
+ self._subscribers: dict[int, _AsyncSubscriber[T]] = {}
126
+ self._next_id = 0
127
+ self._closed = False
128
+ self._lock = asyncio.Lock()
129
+ self._loop: asyncio.AbstractEventLoop | None = None
130
+ self._waiters: set[asyncio.Future[T | None]] = set()
131
+ self._schedules: dict[int, tuple[AsyncSubscription, asyncio.Task[None]]] = {}
132
+
133
+ @property
134
+ def closed(self) -> bool:
135
+ return self._closed
136
+
137
+ @property
138
+ def subscriber_count(self) -> int:
139
+ return len(self._subscribers)
140
+
141
+ @property
142
+ def stats(self) -> EventBusStats:
143
+ return EventBusStats(self._published, self._delivered, self._failed)
144
+
145
+ @property
146
+ def delivery_mode(self) -> DeliveryMode:
147
+ return self._delivery_mode
148
+
149
+ def _bind_loop(self) -> asyncio.AbstractEventLoop:
150
+ loop = asyncio.get_running_loop()
151
+ if self._loop is None:
152
+ self._loop = loop
153
+ elif self._loop is not loop:
154
+ raise EventBusError("AsyncEventBus cannot be shared across event loops")
155
+ return loop
156
+
157
+ async def subscribe(
158
+ self,
159
+ callback: Callable[[T], Awaitable[None]],
160
+ *,
161
+ event_filter: EventFilter[T] | None = None,
162
+ event_type: EventType | None = None,
163
+ predicate: Callable[[T], bool] | None = None,
164
+ once: bool = False,
165
+ times: int | None = None,
166
+ replay: int = 0,
167
+ ) -> AsyncSubscription:
168
+ self._bind_loop()
169
+ if not callable(callback) or not self._is_async_handler(callback):
170
+ raise InvalidEventHandlerError("AsyncEventBus accepts async handlers only")
171
+ replay = validate_nonnegative_count(replay, name="Replay count")
172
+ delivery_limit = self._delivery_limit(once=once, times=times)
173
+ normalized_filter = coerce_event_filter(
174
+ event_filter,
175
+ event_type=event_type,
176
+ predicate=predicate,
177
+ )
178
+ async with self._lock:
179
+ self._ensure_open()
180
+ history = self._history
181
+ if replay and history is not None:
182
+ if event_filter is None and event_type is None and predicate is None:
183
+ replay_snapshot = history.query(limit=replay)
184
+ else:
185
+ replay_snapshot = history.query()
186
+ else:
187
+ replay_snapshot = ()
188
+ subscription, subscriber = self._subscribe_locked(
189
+ callback,
190
+ normalized_filter,
191
+ delivery_limit,
192
+ replaying=bool(replay and history is not None),
193
+ )
194
+
195
+ try:
196
+ replay_events = tuple(
197
+ event for event in replay_snapshot if normalized_filter.matches(event)
198
+ )[-replay:]
199
+
200
+ if subscriber.replaying:
201
+ pending: tuple[T, ...] | None = replay_events
202
+ while pending is not None:
203
+ for event in pending:
204
+ claimed, final = subscriber.claim()
205
+ if not claimed:
206
+ break
207
+ if final:
208
+ await subscription._consume()
209
+ try:
210
+ await callback(event)
211
+ except Exception as exc:
212
+ action_errors = await self._notify_errors(event, (exc,))
213
+ self._failed += 1 + len(action_errors)
214
+ if self._error_policy is ErrorPolicy.RAISE:
215
+ await subscription.cancel()
216
+ raise ExceptionGroup(
217
+ "Async event replay failed",
218
+ [exc, *action_errors],
219
+ ) from exc
220
+ else:
221
+ self._delivered += 1
222
+ if final:
223
+ break
224
+ pending = subscriber.finish_replay()
225
+ except BaseException:
226
+ await asyncio.shield(subscription.cancel())
227
+ raise
228
+ return subscription
229
+
230
+ def _subscribe_locked(
231
+ self,
232
+ callback: Callable[[T], Awaitable[None]],
233
+ event_filter: EventFilter[T],
234
+ delivery_limit: int | None,
235
+ replaying: bool = False,
236
+ ) -> tuple[AsyncSubscription, _AsyncSubscriber[T]]:
237
+ subscription_id = self._next_id
238
+ self._next_id += 1
239
+
240
+ async def cancel() -> None:
241
+ async with self._lock:
242
+ self._subscribers.pop(subscription_id, None)
243
+
244
+ subscription = AsyncSubscription(subscription_id, cancel)
245
+ subscriber = _AsyncSubscriber(
246
+ callback,
247
+ event_filter,
248
+ delivery_limit,
249
+ subscription,
250
+ replaying=replaying,
251
+ pending=deque(maxlen=self._replay_buffer_limit),
252
+ )
253
+ self._subscribers[subscription_id] = subscriber
254
+ return subscription, subscriber
255
+
256
+ async def once(
257
+ self,
258
+ callback: Callable[[T], Awaitable[None]],
259
+ *,
260
+ event_filter: EventFilter[T] | None = None,
261
+ event_type: EventType | None = None,
262
+ predicate: Callable[[T], bool] | None = None,
263
+ replay: int = 0,
264
+ ) -> AsyncSubscription:
265
+ """Subscribe a handler for its first matching event only."""
266
+
267
+ return await self.subscribe(
268
+ callback,
269
+ event_filter=event_filter,
270
+ event_type=event_type,
271
+ predicate=predicate,
272
+ once=True,
273
+ replay=replay,
274
+ )
275
+
276
+ async def publish(self, event: T) -> PublishResult:
277
+ self._bind_loop()
278
+ async with self._lock:
279
+ self._ensure_open()
280
+ subscribers = tuple(self._subscribers.values())
281
+ self._published += 1
282
+ self._record(event)
283
+
284
+ matched: list[_AsyncSubscriber[T]] = []
285
+ errors: list[Exception] = []
286
+ if self._actions.on_before is not None:
287
+ try:
288
+ await self._actions.on_before(event)
289
+ except Exception as exc:
290
+ errors.append(exc)
291
+ for subscriber in subscribers:
292
+ if not subscriber.subscription.active:
293
+ continue
294
+ try:
295
+ matches = subscriber.event_filter.matches(event)
296
+ except Exception as exc:
297
+ errors.append(exc)
298
+ continue
299
+ if matches:
300
+ try:
301
+ if subscriber.buffer_if_replaying(event):
302
+ continue
303
+ except BufferError as exc:
304
+ await subscriber.subscription.cancel()
305
+ errors.append(exc)
306
+ continue
307
+ matched.append(subscriber)
308
+
309
+ delivered = 0
310
+ if self._delivery_mode is DeliveryMode.SEQUENTIAL:
311
+ for subscriber in matched:
312
+ was_delivered, error = await self._deliver(subscriber, event)
313
+ if was_delivered:
314
+ delivered += 1
315
+ if error is not None:
316
+ errors.append(error)
317
+ else:
318
+ if self._max_concurrency is not None and 1 < self._max_concurrency < len(matched):
319
+ outcomes = await self._deliver_window(matched, event, self._max_concurrency)
320
+ delivered += self._collect_concurrent_outcomes(outcomes, errors)
321
+ else:
322
+ limit = self._max_concurrency or max(1, len(matched))
323
+ for offset in range(0, len(matched), limit):
324
+ batch = matched[offset : offset + limit]
325
+ outcomes = await asyncio.gather(
326
+ *(self._deliver(subscriber, event) for subscriber in batch),
327
+ return_exceptions=True,
328
+ )
329
+ delivered += self._collect_concurrent_outcomes(outcomes, errors)
330
+
331
+ errors.extend(await self._notify_errors(event, tuple(errors)))
332
+ result = PublishResult(
333
+ matched=len(matched),
334
+ delivered=delivered,
335
+ failed=len(errors),
336
+ errors=tuple(errors),
337
+ )
338
+ if self._actions.on_after is not None:
339
+ try:
340
+ await self._actions.on_after(event, result)
341
+ except Exception as exc:
342
+ errors.append(exc)
343
+ errors.extend(await self._notify_errors(event, (exc,)))
344
+ result = PublishResult(
345
+ matched=len(matched),
346
+ delivered=delivered,
347
+ failed=len(errors),
348
+ errors=tuple(errors),
349
+ )
350
+ self._delivered += delivered
351
+ self._failed += len(errors)
352
+ if errors and self._error_policy is ErrorPolicy.RAISE:
353
+ raise ExceptionGroup("Async event delivery failed", errors)
354
+ return result
355
+
356
+ @staticmethod
357
+ def _collect_concurrent_outcomes(
358
+ outcomes: list[tuple[bool, Exception | None] | BaseException],
359
+ errors: list[Exception],
360
+ ) -> int:
361
+ delivered = 0
362
+ for outcome in outcomes:
363
+ if isinstance(outcome, asyncio.CancelledError):
364
+ errors.append(EventBusError("An async event callback was cancelled"))
365
+ elif isinstance(outcome, BaseException):
366
+ raise outcome
367
+ else:
368
+ was_delivered, error = outcome
369
+ if was_delivered:
370
+ delivered += 1
371
+ if error is not None:
372
+ errors.append(error)
373
+ return delivered
374
+
375
+ async def _deliver_window(
376
+ self,
377
+ matched: list[_AsyncSubscriber[T]],
378
+ event: T,
379
+ limit: int,
380
+ ) -> list[tuple[bool, Exception | None] | BaseException]:
381
+ """Keep each publish's bounded callback slots occupied as work completes."""
382
+ outcomes: list[tuple[bool, Exception | None] | BaseException | None] = [None] * len(matched)
383
+ active: dict[asyncio.Task[tuple[bool, Exception | None]], int] = {}
384
+ next_index = 0
385
+ fatal_error: BaseException | None = None
386
+
387
+ def start_next() -> None:
388
+ nonlocal next_index
389
+ task = asyncio.create_task(self._deliver(matched[next_index], event))
390
+ active[task] = next_index
391
+ next_index += 1
392
+
393
+ for _ in range(min(limit, len(matched))):
394
+ start_next()
395
+
396
+ try:
397
+ while active:
398
+ done, _ = await asyncio.wait(tuple(active), return_when=asyncio.FIRST_COMPLETED)
399
+ for task in done:
400
+ index = active.pop(task)
401
+ try:
402
+ outcome = task.result()
403
+ except BaseException as exc:
404
+ outcome = exc
405
+ outcomes[index] = outcome
406
+ if (
407
+ isinstance(outcome, BaseException)
408
+ and not isinstance(outcome, asyncio.CancelledError)
409
+ and fatal_error is None
410
+ ):
411
+ fatal_error = outcome
412
+
413
+ if fatal_error is None:
414
+ for _ in range(len(done)):
415
+ if next_index >= len(matched):
416
+ break
417
+ start_next()
418
+ except BaseException:
419
+ pending = tuple(active)
420
+ for task in pending:
421
+ task.cancel()
422
+ if pending:
423
+ await finish_cleanup(asyncio.gather(*pending, return_exceptions=True))
424
+ raise
425
+
426
+ if fatal_error is not None:
427
+ raise fatal_error
428
+ return cast(list[tuple[bool, Exception | None] | BaseException], outcomes)
429
+
430
+ async def _notify_errors(
431
+ self,
432
+ event: T,
433
+ errors: tuple[Exception, ...],
434
+ ) -> list[Exception]:
435
+ action = self._actions.on_error
436
+ if action is None:
437
+ return []
438
+ action_errors: list[Exception] = []
439
+ for error in errors:
440
+ try:
441
+ await action(EventErrorContext(event, error))
442
+ except Exception as exc:
443
+ action_errors.append(exc)
444
+ return action_errors
445
+
446
+ async def _deliver(
447
+ self,
448
+ subscriber: _AsyncSubscriber[T],
449
+ event: T,
450
+ ) -> tuple[bool, Exception | None]:
451
+ claimed, final = subscriber.claim()
452
+ if not claimed:
453
+ return False, None
454
+ if final:
455
+ await subscriber.subscription._consume()
456
+ try:
457
+ await subscriber.callback(event)
458
+ except Exception as exc:
459
+ return False, exc
460
+ return True, None
461
+
462
+ async def publish_every(
463
+ self,
464
+ event: T,
465
+ interval: float,
466
+ *,
467
+ times: int | None = None,
468
+ immediately: bool = True,
469
+ ) -> AsyncSubscription:
470
+ """Publish the same event periodically until cancelled or completed."""
471
+
472
+ self._bind_loop()
473
+ interval = validate_positive_timeout(interval, name="Event publish interval")
474
+ if times is not None and (
475
+ isinstance(times, bool) or not isinstance(times, int) or times <= 0
476
+ ):
477
+ raise ValueError("Periodic publish times must be a positive integer")
478
+
479
+ async with self._lock:
480
+ self._ensure_open()
481
+ if len(self._schedules) >= self._max_schedules:
482
+ raise EventBusError("Maximum periodic schedule capacity reached")
483
+ schedule_id = self._next_id
484
+ self._next_id += 1
485
+ task: asyncio.Task[None] | None = None
486
+ started = False
487
+
488
+ async def cancel() -> None:
489
+ if task is not None and task is not asyncio.current_task():
490
+ task.cancel()
491
+ if not started:
492
+ # A task cancelled before its first step skips run's finally block.
493
+ await asyncio.gather(task, return_exceptions=True)
494
+ cleanup()
495
+
496
+ subscription = AsyncSubscription(schedule_id, cancel)
497
+
498
+ def cleanup(_task: asyncio.Task[None] | None = None) -> None:
499
+ subscription._deactivate()
500
+ self._schedules.pop(schedule_id, None)
501
+
502
+ async def run() -> None:
503
+ nonlocal started
504
+ started = True
505
+ completed = 0
506
+ try:
507
+ if not immediately:
508
+ await asyncio.sleep(interval)
509
+ while subscription.active and not self.closed:
510
+ try:
511
+ await self.publish(event)
512
+ except Exception:
513
+ if self._error_policy is ErrorPolicy.RAISE:
514
+ break
515
+ completed += 1
516
+ if times is not None and completed >= times:
517
+ break
518
+ if not subscription.active:
519
+ break
520
+ await asyncio.sleep(interval)
521
+ finally:
522
+ cleanup()
523
+
524
+ task = asyncio.create_task(run(), name=f"AsyncEventBusPeriodic-{schedule_id}")
525
+ self._schedules[schedule_id] = (subscription, task)
526
+ task.add_done_callback(cleanup)
527
+ return subscription
528
+
529
+ async def wait_for(
530
+ self,
531
+ *,
532
+ event_filter: EventFilter[T] | None = None,
533
+ event_type: EventType | None = None,
534
+ predicate: Callable[[T], bool] | None = None,
535
+ timeout: float | None = None,
536
+ ) -> T | None:
537
+ loop = self._bind_loop()
538
+ if timeout is not None:
539
+ timeout = validate_nonnegative_timeout(timeout, name="Event wait timeout")
540
+ normalized_filter = coerce_event_filter(
541
+ event_filter,
542
+ event_type=event_type,
543
+ predicate=predicate,
544
+ )
545
+ result: asyncio.Future[T | None] = loop.create_future()
546
+
547
+ async def capture(event: T) -> None:
548
+ if not result.done():
549
+ result.set_result(event)
550
+
551
+ async with self._lock:
552
+ self._ensure_open()
553
+ subscription, _subscriber = self._subscribe_locked(
554
+ capture,
555
+ normalized_filter,
556
+ 1,
557
+ )
558
+ self._waiters.add(result)
559
+
560
+ timed_out = False
561
+ try:
562
+ value = await asyncio.wait_for(result, timeout)
563
+ except asyncio.TimeoutError:
564
+ timed_out = True
565
+ value = None
566
+ finally:
567
+ try:
568
+ await asyncio.shield(subscription.cancel())
569
+ finally:
570
+ async with self._lock:
571
+ self._waiters.discard(result)
572
+ if timed_out and self._actions.on_timeout is not None:
573
+ await self._actions.on_timeout(EventTimeoutContext(normalized_filter, timeout))
574
+ return value
575
+
576
+ def publish_threadsafe(self, event: T) -> Future[PublishResult]:
577
+ loop = self._loop
578
+ if loop is None or loop.is_closed() or not loop.is_running():
579
+ raise EventBusError("AsyncEventBus has no active owning event loop")
580
+ operation = self.publish(event)
581
+ try:
582
+ return asyncio.run_coroutine_threadsafe(operation, loop)
583
+ except RuntimeError as exc:
584
+ operation.close()
585
+ raise EventBusError("AsyncEventBus owning event loop is unavailable") from exc
586
+
587
+ async def clear(self) -> None:
588
+ """Remove subscribers and wake waiters; retain history and schedules."""
589
+ self._bind_loop()
590
+ async with self._lock:
591
+ subscriptions = tuple(
592
+ subscriber.subscription for subscriber in self._subscribers.values()
593
+ )
594
+ self._subscribers.clear()
595
+ waiters = tuple(self._waiters)
596
+ for waiter in waiters:
597
+ if not waiter.done():
598
+ waiter.set_result(None)
599
+ for subscription in subscriptions:
600
+ subscription._deactivate()
601
+
602
+ async def close(self) -> None:
603
+ self._bind_loop()
604
+ async with self._lock:
605
+ if self._closed and not self._subscribers and not self._schedules:
606
+ return
607
+ self._closed = True
608
+ subscriptions = tuple(
609
+ subscriber.subscription for subscriber in self._subscribers.values()
610
+ )
611
+ schedules = tuple(self._schedules.values())
612
+ waiters = tuple(self._waiters)
613
+ for waiter in waiters:
614
+ if not waiter.done():
615
+ waiter.set_result(None)
616
+ for subscription in subscriptions:
617
+ await subscription.cancel()
618
+ async with self._lock:
619
+ self._subscribers.pop(subscription.id, None)
620
+ current_task = asyncio.current_task()
621
+ for schedule, task in schedules:
622
+ if task is not current_task:
623
+ task.cancel()
624
+ await schedule.cancel()
625
+ pending_tasks = tuple(
626
+ task for _schedule, task in tuple(self._schedules.values()) if task is not current_task
627
+ )
628
+ if pending_tasks:
629
+ await asyncio.gather(
630
+ *pending_tasks,
631
+ return_exceptions=True,
632
+ )
633
+
634
+ async def __aenter__(self) -> "AsyncEventBus[T]":
635
+ self._bind_loop()
636
+ self._ensure_open()
637
+ return self
638
+
639
+ async def __aexit__(self, *_args: object) -> None:
640
+ await self.close()