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.
- eventflow/__init__.py +123 -0
- eventflow/_callbacks.py +22 -0
- eventflow/_lifecycle.py +31 -0
- eventflow/actions.py +53 -0
- eventflow/async_event_bus.py +640 -0
- eventflow/base.py +180 -0
- eventflow/callback.py +318 -0
- eventflow/compatibility.py +14 -0
- eventflow/context.py +103 -0
- eventflow/contracts.py +40 -0
- eventflow/engine.py +466 -0
- eventflow/errors.py +21 -0
- eventflow/event_bus.py +594 -0
- eventflow/filtering.py +57 -0
- eventflow/history.py +111 -0
- eventflow/observability.py +133 -0
- eventflow/pipeline/__init__.py +7 -0
- eventflow/pipeline/async_.py +129 -0
- eventflow/pipeline/base.py +203 -0
- eventflow/pipeline/contracts.py +24 -0
- eventflow/pipeline/registry.py +95 -0
- eventflow/pipeline/sync.py +92 -0
- eventflow/py.typed +0 -0
- eventflow/scopes.py +185 -0
- eventflow/stream.py +241 -0
- eventflow/subscription.py +116 -0
- flowevent-1.2.dist-info/METADATA +1171 -0
- flowevent-1.2.dist-info/RECORD +31 -0
- flowevent-1.2.dist-info/WHEEL +5 -0
- flowevent-1.2.dist-info/licenses/LICENSE +21 -0
- flowevent-1.2.dist-info/top_level.txt +1 -0
|
@@ -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()
|