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/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)