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
eventflow/event_bus.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Thread-safe synchronous event bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Generic, TypeVar
|
|
13
|
+
|
|
14
|
+
from ._callbacks import synchronous_result
|
|
15
|
+
from .actions import EventBusActions, EventErrorContext, EventTimeoutContext
|
|
16
|
+
from .base import (
|
|
17
|
+
BaseEventBus,
|
|
18
|
+
normalize_replay_capacity,
|
|
19
|
+
validate_nonnegative_count,
|
|
20
|
+
validate_nonnegative_timeout,
|
|
21
|
+
validate_positive_capacity,
|
|
22
|
+
validate_positive_timeout,
|
|
23
|
+
)
|
|
24
|
+
from .contracts import ErrorPolicy, EventBusStats, PublishResult
|
|
25
|
+
from .compatibility import ExceptionGroup
|
|
26
|
+
from .errors import EventBusError, EventShutdownTimeoutError, InvalidEventHandlerError
|
|
27
|
+
from .filtering import EventFilter, EventType, coerce_event_filter
|
|
28
|
+
from .history import EventHistory
|
|
29
|
+
from .subscription import Subscription
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
_executor_context = threading.local()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_executor(executor: object) -> ThreadPoolExecutor | None:
|
|
37
|
+
if executor is not None and not isinstance(executor, ThreadPoolExecutor):
|
|
38
|
+
raise TypeError("executor must be a ThreadPoolExecutor or None")
|
|
39
|
+
return executor
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _invoke_sync_callback(callback: Callable[[T], None], event: T) -> None:
|
|
43
|
+
result = callback(event)
|
|
44
|
+
if result is None or not inspect.isawaitable(result):
|
|
45
|
+
return
|
|
46
|
+
if inspect.iscoroutine(result):
|
|
47
|
+
result.close()
|
|
48
|
+
raise InvalidEventHandlerError(
|
|
49
|
+
"Synchronous handlers must not return awaitables; use AsyncEventBus"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class _Subscriber(Generic[T]):
|
|
55
|
+
callback: Callable[[T], None]
|
|
56
|
+
event_filter: EventFilter[T]
|
|
57
|
+
remaining: int | None
|
|
58
|
+
subscription: Subscription
|
|
59
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
60
|
+
replaying: bool = False
|
|
61
|
+
pending: deque[T] = field(default_factory=deque)
|
|
62
|
+
|
|
63
|
+
def claim(self) -> tuple[bool, bool]:
|
|
64
|
+
"""Reserve one delivery and report whether it is the final one."""
|
|
65
|
+
|
|
66
|
+
with self.lock:
|
|
67
|
+
if not self.subscription.active:
|
|
68
|
+
return False, False
|
|
69
|
+
if self.remaining is None:
|
|
70
|
+
return True, False
|
|
71
|
+
if self.remaining <= 0:
|
|
72
|
+
return False, False
|
|
73
|
+
self.remaining -= 1
|
|
74
|
+
return True, self.remaining == 0
|
|
75
|
+
|
|
76
|
+
def buffer_if_replaying(self, event: T) -> bool:
|
|
77
|
+
with self.lock:
|
|
78
|
+
if not self.replaying:
|
|
79
|
+
return False
|
|
80
|
+
if self.pending.maxlen is not None and len(self.pending) >= self.pending.maxlen:
|
|
81
|
+
raise BufferError("Event replay buffer is full")
|
|
82
|
+
self.pending.append(event)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def finish_replay(self) -> tuple[T, ...] | None:
|
|
86
|
+
with self.lock:
|
|
87
|
+
if self.pending:
|
|
88
|
+
events = tuple(self.pending)
|
|
89
|
+
self.pending.clear()
|
|
90
|
+
return events
|
|
91
|
+
self.replaying = False
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EventBus(BaseEventBus[T]):
|
|
96
|
+
"""Publish events safely from any thread to synchronous subscribers."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
history: EventHistory[T] | int | None = None,
|
|
102
|
+
error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
|
|
103
|
+
executor: ThreadPoolExecutor | None = None,
|
|
104
|
+
replay_buffer_limit: int = 1_000,
|
|
105
|
+
max_schedules: int = 64,
|
|
106
|
+
shutdown_timeout: float = 5.0,
|
|
107
|
+
actions: EventBusActions[T] | None = None,
|
|
108
|
+
on_before: Callable[[T], None] | None = None,
|
|
109
|
+
on_after: Callable[[T, PublishResult], None] | None = None,
|
|
110
|
+
on_error: Callable[[EventErrorContext[T]], None] | None = None,
|
|
111
|
+
on_timeout: Callable[[EventTimeoutContext[T]], None] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
super().__init__(history=history, error_policy=error_policy)
|
|
114
|
+
replay_buffer_limit = normalize_replay_capacity(replay_buffer_limit)
|
|
115
|
+
max_schedules = validate_positive_capacity(
|
|
116
|
+
max_schedules,
|
|
117
|
+
name="Maximum periodic schedules",
|
|
118
|
+
)
|
|
119
|
+
shutdown_timeout = validate_positive_timeout(
|
|
120
|
+
shutdown_timeout,
|
|
121
|
+
name="Shutdown timeout",
|
|
122
|
+
)
|
|
123
|
+
direct_actions = (on_before, on_after, on_error, on_timeout)
|
|
124
|
+
if actions is not None and any(action is not None for action in direct_actions):
|
|
125
|
+
raise ValueError("Use actions or direct on_* callbacks, not both")
|
|
126
|
+
self._actions = actions or EventBusActions(
|
|
127
|
+
on_before=on_before,
|
|
128
|
+
on_after=on_after,
|
|
129
|
+
on_error=on_error,
|
|
130
|
+
on_timeout=on_timeout,
|
|
131
|
+
)
|
|
132
|
+
for action in (
|
|
133
|
+
self._actions.on_before,
|
|
134
|
+
self._actions.on_after,
|
|
135
|
+
self._actions.on_error,
|
|
136
|
+
self._actions.on_timeout,
|
|
137
|
+
):
|
|
138
|
+
if action is not None and self._is_async_handler(action):
|
|
139
|
+
raise InvalidEventHandlerError("EventBus actions must be synchronous")
|
|
140
|
+
self._executor = _validate_executor(executor)
|
|
141
|
+
self._replay_buffer_limit = replay_buffer_limit
|
|
142
|
+
self._max_schedules = max_schedules
|
|
143
|
+
self._shutdown_timeout = shutdown_timeout
|
|
144
|
+
self._subscribers: dict[int, _Subscriber[T]] = {}
|
|
145
|
+
self._next_id = 0
|
|
146
|
+
self._closed = False
|
|
147
|
+
self._lock = threading.RLock()
|
|
148
|
+
self._condition = threading.Condition(self._lock)
|
|
149
|
+
self._clear_generation = 0
|
|
150
|
+
self._schedules: dict[int, tuple[Subscription, threading.Thread]] = {}
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def closed(self) -> bool:
|
|
154
|
+
with self._lock:
|
|
155
|
+
return self._closed
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def subscriber_count(self) -> int:
|
|
159
|
+
with self._lock:
|
|
160
|
+
return len(self._subscribers)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def stats(self) -> EventBusStats:
|
|
164
|
+
with self._lock:
|
|
165
|
+
return EventBusStats(self._published, self._delivered, self._failed)
|
|
166
|
+
|
|
167
|
+
def subscribe(
|
|
168
|
+
self,
|
|
169
|
+
callback: Callable[[T], None],
|
|
170
|
+
*,
|
|
171
|
+
event_filter: EventFilter[T] | None = None,
|
|
172
|
+
event_type: EventType | None = None,
|
|
173
|
+
predicate: Callable[[T], bool] | None = None,
|
|
174
|
+
once: bool = False,
|
|
175
|
+
times: int | None = None,
|
|
176
|
+
replay: int = 0,
|
|
177
|
+
) -> Subscription:
|
|
178
|
+
if not callable(callback):
|
|
179
|
+
raise InvalidEventHandlerError("Event handler must be callable")
|
|
180
|
+
if self._is_async_handler(callback):
|
|
181
|
+
raise InvalidEventHandlerError(
|
|
182
|
+
"EventBus accepts synchronous handlers; use AsyncEventBus"
|
|
183
|
+
)
|
|
184
|
+
replay = validate_nonnegative_count(replay, name="Replay count")
|
|
185
|
+
delivery_limit = self._delivery_limit(once=once, times=times)
|
|
186
|
+
normalized_filter = coerce_event_filter(
|
|
187
|
+
event_filter,
|
|
188
|
+
event_type=event_type,
|
|
189
|
+
predicate=predicate,
|
|
190
|
+
)
|
|
191
|
+
with self._lock:
|
|
192
|
+
self._ensure_open()
|
|
193
|
+
history = self._history
|
|
194
|
+
subscription_id = self._next_id
|
|
195
|
+
self._next_id += 1
|
|
196
|
+
if replay and history is not None:
|
|
197
|
+
if event_filter is None and event_type is None and predicate is None:
|
|
198
|
+
replay_snapshot = history.query(limit=replay)
|
|
199
|
+
else:
|
|
200
|
+
replay_snapshot = history.query()
|
|
201
|
+
else:
|
|
202
|
+
replay_snapshot = ()
|
|
203
|
+
|
|
204
|
+
def cancel() -> None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
self._subscribers.pop(subscription_id, None)
|
|
207
|
+
|
|
208
|
+
subscription = Subscription(subscription_id, cancel)
|
|
209
|
+
subscriber = _Subscriber(
|
|
210
|
+
callback,
|
|
211
|
+
normalized_filter,
|
|
212
|
+
delivery_limit,
|
|
213
|
+
subscription,
|
|
214
|
+
replaying=bool(replay and history is not None),
|
|
215
|
+
pending=deque(maxlen=self._replay_buffer_limit),
|
|
216
|
+
)
|
|
217
|
+
self._subscribers[subscription_id] = subscriber
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
replay_events = tuple(
|
|
221
|
+
event for event in replay_snapshot if normalized_filter.matches(event)
|
|
222
|
+
)[-replay:]
|
|
223
|
+
|
|
224
|
+
if subscriber.replaying:
|
|
225
|
+
pending: tuple[T, ...] | None = replay_events
|
|
226
|
+
while pending is not None:
|
|
227
|
+
for event in pending:
|
|
228
|
+
claimed, final = subscriber.claim()
|
|
229
|
+
if not claimed:
|
|
230
|
+
break
|
|
231
|
+
if final:
|
|
232
|
+
subscription._consume()
|
|
233
|
+
try:
|
|
234
|
+
_invoke_sync_callback(callback, event)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
action_errors = self._notify_errors(event, (exc,))
|
|
237
|
+
with self._lock:
|
|
238
|
+
self._failed += 1 + len(action_errors)
|
|
239
|
+
if self._error_policy is ErrorPolicy.RAISE:
|
|
240
|
+
subscription.cancel()
|
|
241
|
+
raise ExceptionGroup(
|
|
242
|
+
"Event replay failed",
|
|
243
|
+
[exc, *action_errors],
|
|
244
|
+
) from exc
|
|
245
|
+
else:
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._delivered += 1
|
|
248
|
+
if final:
|
|
249
|
+
break
|
|
250
|
+
pending = subscriber.finish_replay()
|
|
251
|
+
except BaseException:
|
|
252
|
+
subscription.cancel()
|
|
253
|
+
raise
|
|
254
|
+
return subscription
|
|
255
|
+
|
|
256
|
+
def once(
|
|
257
|
+
self,
|
|
258
|
+
callback: Callable[[T], 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
|
+
) -> Subscription:
|
|
265
|
+
"""Subscribe a handler for its first matching event only."""
|
|
266
|
+
|
|
267
|
+
return 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
|
+
def publish(self, event: T) -> PublishResult:
|
|
277
|
+
with self._lock:
|
|
278
|
+
self._ensure_open()
|
|
279
|
+
subscribers = tuple(self._subscribers.values())
|
|
280
|
+
self._published += 1
|
|
281
|
+
self._record(event)
|
|
282
|
+
|
|
283
|
+
matched: list[_Subscriber[T]] = []
|
|
284
|
+
errors: list[Exception] = []
|
|
285
|
+
if self._actions.on_before is not None:
|
|
286
|
+
try:
|
|
287
|
+
synchronous_result(self._actions.on_before(event))
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
errors.append(exc)
|
|
290
|
+
for subscriber in subscribers:
|
|
291
|
+
if not subscriber.subscription.active:
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
matches = subscriber.event_filter.matches(event)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
errors.append(exc)
|
|
297
|
+
continue
|
|
298
|
+
if matches:
|
|
299
|
+
try:
|
|
300
|
+
if subscriber.buffer_if_replaying(event):
|
|
301
|
+
continue
|
|
302
|
+
except BufferError as exc:
|
|
303
|
+
subscriber.subscription.cancel()
|
|
304
|
+
errors.append(exc)
|
|
305
|
+
continue
|
|
306
|
+
matched.append(subscriber)
|
|
307
|
+
|
|
308
|
+
delivered = 0
|
|
309
|
+
active_executors = getattr(_executor_context, "active", frozenset())
|
|
310
|
+
if self._executor is None or id(self._executor) in active_executors:
|
|
311
|
+
for subscriber in matched:
|
|
312
|
+
was_delivered, error = self._deliver(subscriber, event)
|
|
313
|
+
if was_delivered:
|
|
314
|
+
delivered += 1
|
|
315
|
+
if error is not None:
|
|
316
|
+
errors.append(error)
|
|
317
|
+
else:
|
|
318
|
+
futures: list[tuple[_Subscriber[T], Future[None]]] = []
|
|
319
|
+
for subscriber in matched:
|
|
320
|
+
claimed, final = subscriber.claim()
|
|
321
|
+
if not claimed:
|
|
322
|
+
continue
|
|
323
|
+
if final:
|
|
324
|
+
subscriber.subscription._consume()
|
|
325
|
+
try:
|
|
326
|
+
future = self._executor.submit(
|
|
327
|
+
self._invoke_executor_callback,
|
|
328
|
+
subscriber.callback,
|
|
329
|
+
event,
|
|
330
|
+
active_executors,
|
|
331
|
+
)
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
errors.append(exc)
|
|
334
|
+
else:
|
|
335
|
+
futures.append((subscriber, future))
|
|
336
|
+
for _subscriber, future in futures:
|
|
337
|
+
try:
|
|
338
|
+
future.result()
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
errors.append(exc)
|
|
341
|
+
else:
|
|
342
|
+
delivered += 1
|
|
343
|
+
|
|
344
|
+
errors.extend(self._notify_errors(event, tuple(errors)))
|
|
345
|
+
result = PublishResult(
|
|
346
|
+
matched=len(matched),
|
|
347
|
+
delivered=delivered,
|
|
348
|
+
failed=len(errors),
|
|
349
|
+
errors=tuple(errors),
|
|
350
|
+
)
|
|
351
|
+
if self._actions.on_after is not None:
|
|
352
|
+
try:
|
|
353
|
+
synchronous_result(self._actions.on_after(event, result))
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
errors.append(exc)
|
|
356
|
+
errors.extend(self._notify_errors(event, (exc,)))
|
|
357
|
+
result = PublishResult(
|
|
358
|
+
matched=len(matched),
|
|
359
|
+
delivered=delivered,
|
|
360
|
+
failed=len(errors),
|
|
361
|
+
errors=tuple(errors),
|
|
362
|
+
)
|
|
363
|
+
with self._condition:
|
|
364
|
+
self._delivered += delivered
|
|
365
|
+
self._failed += len(errors)
|
|
366
|
+
self._condition.notify_all()
|
|
367
|
+
if errors and self._error_policy is ErrorPolicy.RAISE:
|
|
368
|
+
raise ExceptionGroup("Event delivery failed", errors)
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
def _invoke_executor_callback(
|
|
372
|
+
self,
|
|
373
|
+
callback: Callable[[T], None],
|
|
374
|
+
event: T,
|
|
375
|
+
inherited_executors: frozenset[int],
|
|
376
|
+
) -> None:
|
|
377
|
+
previous = getattr(_executor_context, "active", None)
|
|
378
|
+
active = set(inherited_executors)
|
|
379
|
+
if previous is not None:
|
|
380
|
+
active.update(previous)
|
|
381
|
+
if self._executor is not None:
|
|
382
|
+
active.add(id(self._executor))
|
|
383
|
+
_executor_context.active = frozenset(active)
|
|
384
|
+
try:
|
|
385
|
+
_invoke_sync_callback(callback, event)
|
|
386
|
+
finally:
|
|
387
|
+
if previous is None:
|
|
388
|
+
del _executor_context.active
|
|
389
|
+
else:
|
|
390
|
+
_executor_context.active = previous
|
|
391
|
+
|
|
392
|
+
def _notify_errors(
|
|
393
|
+
self,
|
|
394
|
+
event: T,
|
|
395
|
+
errors: tuple[Exception, ...],
|
|
396
|
+
) -> list[Exception]:
|
|
397
|
+
action = self._actions.on_error
|
|
398
|
+
if action is None:
|
|
399
|
+
return []
|
|
400
|
+
action_errors: list[Exception] = []
|
|
401
|
+
for error in errors:
|
|
402
|
+
try:
|
|
403
|
+
synchronous_result(action(EventErrorContext(event, error)))
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
action_errors.append(exc)
|
|
406
|
+
return action_errors
|
|
407
|
+
|
|
408
|
+
def _deliver(
|
|
409
|
+
self,
|
|
410
|
+
subscriber: _Subscriber[T],
|
|
411
|
+
event: T,
|
|
412
|
+
) -> tuple[bool, Exception | None]:
|
|
413
|
+
claimed, final = subscriber.claim()
|
|
414
|
+
if not claimed:
|
|
415
|
+
return False, None
|
|
416
|
+
if final:
|
|
417
|
+
subscriber.subscription._consume()
|
|
418
|
+
try:
|
|
419
|
+
_invoke_sync_callback(subscriber.callback, event)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
return False, exc
|
|
422
|
+
return True, None
|
|
423
|
+
|
|
424
|
+
def publish_every(
|
|
425
|
+
self,
|
|
426
|
+
event: T,
|
|
427
|
+
interval: float,
|
|
428
|
+
*,
|
|
429
|
+
times: int | None = None,
|
|
430
|
+
immediately: bool = True,
|
|
431
|
+
) -> Subscription:
|
|
432
|
+
"""Publish the same event periodically until cancelled or completed."""
|
|
433
|
+
|
|
434
|
+
interval = validate_positive_timeout(interval, name="Event publish interval")
|
|
435
|
+
if times is not None and (
|
|
436
|
+
isinstance(times, bool) or not isinstance(times, int) or times <= 0
|
|
437
|
+
):
|
|
438
|
+
raise ValueError("Periodic publish times must be a positive integer")
|
|
439
|
+
|
|
440
|
+
stopped = threading.Event()
|
|
441
|
+
with self._lock:
|
|
442
|
+
self._ensure_open()
|
|
443
|
+
if len(self._schedules) >= self._max_schedules:
|
|
444
|
+
raise EventBusError("Maximum periodic schedule capacity reached")
|
|
445
|
+
schedule_id = self._next_id
|
|
446
|
+
self._next_id += 1
|
|
447
|
+
|
|
448
|
+
def cancel() -> None:
|
|
449
|
+
stopped.set()
|
|
450
|
+
|
|
451
|
+
subscription = Subscription(schedule_id, cancel)
|
|
452
|
+
|
|
453
|
+
def run() -> None:
|
|
454
|
+
completed = 0
|
|
455
|
+
try:
|
|
456
|
+
if not immediately and stopped.wait(interval):
|
|
457
|
+
return
|
|
458
|
+
while subscription.active and not self.closed:
|
|
459
|
+
try:
|
|
460
|
+
self.publish(event)
|
|
461
|
+
except Exception:
|
|
462
|
+
if self._error_policy is ErrorPolicy.RAISE:
|
|
463
|
+
break
|
|
464
|
+
completed += 1
|
|
465
|
+
if times is not None and completed >= times:
|
|
466
|
+
break
|
|
467
|
+
if not subscription.active:
|
|
468
|
+
break
|
|
469
|
+
if stopped.wait(interval):
|
|
470
|
+
break
|
|
471
|
+
finally:
|
|
472
|
+
subscription._deactivate()
|
|
473
|
+
with self._lock:
|
|
474
|
+
self._schedules.pop(schedule_id, None)
|
|
475
|
+
|
|
476
|
+
thread = threading.Thread(
|
|
477
|
+
target=run,
|
|
478
|
+
name=f"EventBusPeriodic-{schedule_id}",
|
|
479
|
+
daemon=True,
|
|
480
|
+
)
|
|
481
|
+
with self._lock:
|
|
482
|
+
self._ensure_open()
|
|
483
|
+
self._schedules[schedule_id] = (subscription, thread)
|
|
484
|
+
try:
|
|
485
|
+
thread.start()
|
|
486
|
+
except BaseException:
|
|
487
|
+
self._schedules.pop(schedule_id, None)
|
|
488
|
+
subscription._deactivate()
|
|
489
|
+
stopped.set()
|
|
490
|
+
raise
|
|
491
|
+
return subscription
|
|
492
|
+
|
|
493
|
+
def wait_for(
|
|
494
|
+
self,
|
|
495
|
+
*,
|
|
496
|
+
event_filter: EventFilter[T] | None = None,
|
|
497
|
+
event_type: EventType | None = None,
|
|
498
|
+
predicate: Callable[[T], bool] | None = None,
|
|
499
|
+
timeout: float | None = None,
|
|
500
|
+
) -> T | None:
|
|
501
|
+
if timeout is not None:
|
|
502
|
+
timeout = validate_nonnegative_timeout(timeout, name="Event wait timeout")
|
|
503
|
+
normalized_filter = coerce_event_filter(
|
|
504
|
+
event_filter,
|
|
505
|
+
event_type=event_type,
|
|
506
|
+
predicate=predicate,
|
|
507
|
+
)
|
|
508
|
+
received: list[T] = []
|
|
509
|
+
|
|
510
|
+
def capture(event: T) -> None:
|
|
511
|
+
with self._condition:
|
|
512
|
+
received.append(event)
|
|
513
|
+
self._condition.notify_all()
|
|
514
|
+
|
|
515
|
+
with self._condition:
|
|
516
|
+
# One-shot claim deactivates before capture; only clear/close abort a wait.
|
|
517
|
+
clear_generation = self._clear_generation
|
|
518
|
+
subscription = self.subscribe(
|
|
519
|
+
capture,
|
|
520
|
+
event_filter=normalized_filter,
|
|
521
|
+
once=True,
|
|
522
|
+
)
|
|
523
|
+
timed_out = False
|
|
524
|
+
try:
|
|
525
|
+
with self._condition:
|
|
526
|
+
ready = self._condition.wait_for(
|
|
527
|
+
lambda: (
|
|
528
|
+
bool(received) or self._closed or self._clear_generation != clear_generation
|
|
529
|
+
),
|
|
530
|
+
timeout=timeout,
|
|
531
|
+
)
|
|
532
|
+
timed_out = not ready
|
|
533
|
+
value = received[0] if ready and received else None
|
|
534
|
+
finally:
|
|
535
|
+
subscription.cancel()
|
|
536
|
+
if timed_out and self._actions.on_timeout is not None:
|
|
537
|
+
synchronous_result(
|
|
538
|
+
self._actions.on_timeout(EventTimeoutContext(normalized_filter, timeout))
|
|
539
|
+
)
|
|
540
|
+
return value
|
|
541
|
+
|
|
542
|
+
def clear(self) -> None:
|
|
543
|
+
"""Remove subscribers and wake waiters; retain history and schedules."""
|
|
544
|
+
with self._lock:
|
|
545
|
+
self._clear_generation += 1
|
|
546
|
+
subscriptions = tuple(
|
|
547
|
+
subscriber.subscription for subscriber in self._subscribers.values()
|
|
548
|
+
)
|
|
549
|
+
self._subscribers.clear()
|
|
550
|
+
for subscription in subscriptions:
|
|
551
|
+
subscription._deactivate()
|
|
552
|
+
with self._condition:
|
|
553
|
+
self._condition.notify_all()
|
|
554
|
+
|
|
555
|
+
def close(self) -> None:
|
|
556
|
+
with self._condition:
|
|
557
|
+
if self._closed and not self._schedules:
|
|
558
|
+
return
|
|
559
|
+
self._closed = True
|
|
560
|
+
subscriptions = tuple(
|
|
561
|
+
subscriber.subscription for subscriber in self._subscribers.values()
|
|
562
|
+
)
|
|
563
|
+
schedules = tuple(self._schedules.values())
|
|
564
|
+
self._subscribers.clear()
|
|
565
|
+
self._condition.notify_all()
|
|
566
|
+
for subscription in subscriptions:
|
|
567
|
+
subscription._deactivate()
|
|
568
|
+
for schedule, _thread in schedules:
|
|
569
|
+
schedule.cancel()
|
|
570
|
+
deadline = time.monotonic() + self._shutdown_timeout
|
|
571
|
+
pending: list[str] = []
|
|
572
|
+
for _schedule, thread in schedules:
|
|
573
|
+
if thread is not threading.current_thread():
|
|
574
|
+
thread.join(max(0.0, deadline - time.monotonic()))
|
|
575
|
+
if thread.is_alive():
|
|
576
|
+
pending.append(thread.name)
|
|
577
|
+
with self._lock:
|
|
578
|
+
for schedule_id, (_schedule, thread) in tuple(self._schedules.items()):
|
|
579
|
+
if not thread.is_alive():
|
|
580
|
+
self._schedules.pop(schedule_id, None)
|
|
581
|
+
if pending:
|
|
582
|
+
raise EventShutdownTimeoutError(
|
|
583
|
+
"Periodic event schedules did not stop in time: " + ", ".join(pending)
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
def __len__(self) -> int:
|
|
587
|
+
return self.subscriber_count
|
|
588
|
+
|
|
589
|
+
def __enter__(self) -> "EventBus[T]":
|
|
590
|
+
self._ensure_open()
|
|
591
|
+
return self
|
|
592
|
+
|
|
593
|
+
def __exit__(self, *_args: object) -> None:
|
|
594
|
+
self.close()
|
eventflow/filtering.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Composable type and predicate filters for event subscriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
EventType = type[Any] | tuple[type[Any], ...]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class EventFilter(Generic[T]):
|
|
17
|
+
event_type: EventType | None = None
|
|
18
|
+
predicate: Callable[[T], bool] | None = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
event_types = self.event_type
|
|
22
|
+
if event_types is not None:
|
|
23
|
+
values = event_types if isinstance(event_types, tuple) else (event_types,)
|
|
24
|
+
if not values or any(not isinstance(value, type) for value in values):
|
|
25
|
+
raise TypeError("Event filter types must contain one or more classes")
|
|
26
|
+
if self.predicate is not None and not callable(self.predicate):
|
|
27
|
+
raise TypeError("Event filter predicate must be callable")
|
|
28
|
+
if self.predicate is not None and (
|
|
29
|
+
inspect.iscoroutinefunction(self.predicate)
|
|
30
|
+
or inspect.iscoroutinefunction(type(self.predicate).__call__)
|
|
31
|
+
):
|
|
32
|
+
raise TypeError("Event filter predicates must be synchronous")
|
|
33
|
+
|
|
34
|
+
def matches(self, event: T) -> bool:
|
|
35
|
+
if self.event_type is not None and not isinstance(event, self.event_type):
|
|
36
|
+
return False
|
|
37
|
+
if self.predicate is None:
|
|
38
|
+
return True
|
|
39
|
+
result = self.predicate(event)
|
|
40
|
+
if inspect.isawaitable(result):
|
|
41
|
+
if inspect.iscoroutine(result):
|
|
42
|
+
result.close()
|
|
43
|
+
raise TypeError("Event filter predicates must return a boolean synchronously")
|
|
44
|
+
if type(result) is not bool:
|
|
45
|
+
raise TypeError("Event filter predicates must return bool")
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def coerce_event_filter(
|
|
50
|
+
event_filter: EventFilter[T] | None = None,
|
|
51
|
+
*,
|
|
52
|
+
event_type: EventType | None = None,
|
|
53
|
+
predicate: Callable[[T], bool] | None = None,
|
|
54
|
+
) -> EventFilter[T]:
|
|
55
|
+
if event_filter is not None and (event_type is not None or predicate is not None):
|
|
56
|
+
raise ValueError("Use event_filter or event_type/predicate, not both")
|
|
57
|
+
return event_filter or EventFilter(event_type=event_type, predicate=predicate)
|