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/engine.py ADDED
@@ -0,0 +1,466 @@
1
+ """Application-level engines for managing named event channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Awaitable, Callable
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from typing import Any
9
+
10
+ from .actions import (
11
+ AsyncEventBusActions,
12
+ EventBusActions,
13
+ EventErrorContext,
14
+ EventTimeoutContext,
15
+ )
16
+ from .async_event_bus import AsyncEventBus
17
+ from .base import validate_positive_capacity
18
+ from .compatibility import ExceptionGroup
19
+ from .contracts import DeliveryMode, ErrorPolicy, PublishResult
20
+ from .errors import EventBusClosedError
21
+ from .event_bus import EventBus, _validate_executor
22
+ from .filtering import EventFilter, EventType
23
+ from .subscription import AsyncSubscription, Subscription
24
+
25
+
26
+ def _channel_name(name: str) -> str:
27
+ normalized = str(name).strip().lower()
28
+ if not normalized:
29
+ raise ValueError("Event channel name cannot be empty")
30
+ return normalized
31
+
32
+
33
+ def _validate_history_capacity(history: object) -> int | None:
34
+ if history is None:
35
+ return None
36
+ if isinstance(history, bool) or not isinstance(history, int):
37
+ raise TypeError("Engine history must be a positive integer capacity or None")
38
+ if history <= 0:
39
+ raise ValueError("Engine history capacity must be positive")
40
+ return history
41
+
42
+
43
+ class EventEngine:
44
+ """Create and manage thread-safe event buses by a readable channel name."""
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ history: int | None = None,
50
+ error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
51
+ executor: ThreadPoolExecutor | None = None,
52
+ actions: EventBusActions[Any] | None = None,
53
+ on_before: Callable[[Any], None] | None = None,
54
+ on_after: Callable[[Any, PublishResult], None] | None = None,
55
+ on_error: Callable[[EventErrorContext[Any]], None] | None = None,
56
+ on_timeout: Callable[[EventTimeoutContext[Any]], None] | None = None,
57
+ ) -> None:
58
+ self._history = _validate_history_capacity(history)
59
+ self._error_policy = ErrorPolicy(error_policy)
60
+ self._executor = _validate_executor(executor)
61
+ direct_actions = (on_before, on_after, on_error, on_timeout)
62
+ if actions is not None and any(action is not None for action in direct_actions):
63
+ raise ValueError("Use actions or direct on_* callbacks, not both")
64
+ self._actions = actions or EventBusActions(
65
+ on_before=on_before,
66
+ on_after=on_after,
67
+ on_error=on_error,
68
+ on_timeout=on_timeout,
69
+ )
70
+ self._channels: dict[str, EventBus[Any]] = {}
71
+ self._pending_close: dict[int, EventBus[Any]] = {}
72
+ self._lock = threading.RLock()
73
+ self._running = True
74
+
75
+ @property
76
+ def running(self) -> bool:
77
+ with self._lock:
78
+ return self._running
79
+
80
+ @property
81
+ def channel_names(self) -> tuple[str, ...]:
82
+ with self._lock:
83
+ return tuple(sorted(self._channels))
84
+
85
+ def channel(self, name: str) -> EventBus[Any]:
86
+ """Return a channel, creating it with engine defaults when necessary."""
87
+
88
+ name = _channel_name(name)
89
+ with self._lock:
90
+ bus = self._channels.get(name)
91
+ if bus is None:
92
+ bus = EventBus(
93
+ history=self._history,
94
+ error_policy=self._error_policy,
95
+ executor=self._executor,
96
+ actions=self._actions,
97
+ )
98
+ self._channels[name] = bus
99
+ self._running = True
100
+ return bus
101
+
102
+ def add(self, name: str, bus: EventBus[Any]) -> EventBus[Any]:
103
+ """Register a bus owned by this engine under a channel name.
104
+
105
+ An open bus cannot be claimed by another engine; that registration raises
106
+ ValueError.
107
+ """
108
+
109
+ if not isinstance(bus, EventBus):
110
+ raise TypeError("Event engine channels must be EventBus instances")
111
+ if bus.closed:
112
+ raise EventBusClosedError("Cannot add a closed EventBus to an engine")
113
+ name = _channel_name(name)
114
+ with self._lock:
115
+ if name in self._channels:
116
+ raise ValueError(f"Event channel already exists: {name}")
117
+ if any(channel is bus for channel in self._channels.values()) or any(
118
+ channel is bus for channel in self._pending_close.values()
119
+ ):
120
+ raise ValueError("An EventBus instance can belong to only one channel")
121
+ self._channels[name] = bus
122
+ if not bus._claim_engine_ownership(self):
123
+ del self._channels[name]
124
+ raise ValueError("An EventBus instance can belong to only one engine")
125
+ self._running = True
126
+ return bus
127
+
128
+ def publish(self, channel: str, event: Any) -> PublishResult:
129
+ return self.channel(channel).publish(event)
130
+
131
+ def subscribe(
132
+ self,
133
+ channel: str,
134
+ callback: Callable[[Any], None],
135
+ *,
136
+ event_filter: EventFilter[Any] | None = None,
137
+ event_type: EventType | None = None,
138
+ predicate: Callable[[Any], bool] | None = None,
139
+ once: bool = False,
140
+ times: int | None = None,
141
+ replay: int = 0,
142
+ ) -> Subscription:
143
+ return self.channel(channel).subscribe(
144
+ callback,
145
+ event_filter=event_filter,
146
+ event_type=event_type,
147
+ predicate=predicate,
148
+ once=once,
149
+ times=times,
150
+ replay=replay,
151
+ )
152
+
153
+ def once(
154
+ self,
155
+ channel: str,
156
+ callback: Callable[[Any], None],
157
+ *,
158
+ event_filter: EventFilter[Any] | None = None,
159
+ event_type: EventType | None = None,
160
+ predicate: Callable[[Any], bool] | None = None,
161
+ replay: int = 0,
162
+ ) -> Subscription:
163
+ return self.channel(channel).once(
164
+ callback,
165
+ event_filter=event_filter,
166
+ event_type=event_type,
167
+ predicate=predicate,
168
+ replay=replay,
169
+ )
170
+
171
+ def publish_every(
172
+ self,
173
+ channel: str,
174
+ event: Any,
175
+ interval: float,
176
+ *,
177
+ times: int | None = None,
178
+ immediately: bool = True,
179
+ ) -> Subscription:
180
+ return self.channel(channel).publish_every(
181
+ event,
182
+ interval,
183
+ times=times,
184
+ immediately=immediately,
185
+ )
186
+
187
+ def wait_for(
188
+ self,
189
+ channel: str,
190
+ *,
191
+ event_filter: EventFilter[Any] | None = None,
192
+ event_type: EventType | None = None,
193
+ predicate: Callable[[Any], bool] | None = None,
194
+ timeout: float | None = None,
195
+ ) -> Any | None:
196
+ return self.channel(channel).wait_for(
197
+ event_filter=event_filter,
198
+ event_type=event_type,
199
+ predicate=predicate,
200
+ timeout=timeout,
201
+ )
202
+
203
+ def remove(self, name: str) -> bool:
204
+ name = _channel_name(name)
205
+ with self._lock:
206
+ bus = self._channels.pop(name, None)
207
+ if bus is not None:
208
+ self._pending_close[id(bus)] = bus
209
+ if bus is None:
210
+ return False
211
+ bus.close()
212
+ with self._lock:
213
+ self._pending_close.pop(id(bus), None)
214
+ return True
215
+
216
+ def start(self) -> None:
217
+ """Keep Service-style lifecycle symmetry; channels start lazily."""
218
+
219
+ with self._lock:
220
+ self._running = True
221
+
222
+ def stop(self) -> None:
223
+ with self._lock:
224
+ channels = tuple(self._channels.values())
225
+ self._channels.clear()
226
+ for channel in channels:
227
+ self._pending_close[id(channel)] = channel
228
+ pending = tuple(self._pending_close.values())
229
+ self._running = False
230
+ errors: list[Exception] = []
231
+ for channel in pending:
232
+ try:
233
+ channel.close()
234
+ except Exception as exc:
235
+ errors.append(exc)
236
+ else:
237
+ with self._lock:
238
+ self._pending_close.pop(id(channel), None)
239
+ if errors:
240
+ raise ExceptionGroup("One or more event channels failed to close", errors)
241
+
242
+ close = stop
243
+
244
+ def __enter__(self) -> "EventEngine":
245
+ return self
246
+
247
+ def __exit__(self, *_args: object) -> None:
248
+ self.stop()
249
+
250
+
251
+ class AsyncEventEngine:
252
+ """Create and manage asyncio-native event buses by channel name."""
253
+
254
+ def __init__(
255
+ self,
256
+ *,
257
+ history: int | None = None,
258
+ error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
259
+ delivery_mode: DeliveryMode = DeliveryMode.SEQUENTIAL,
260
+ max_concurrency: int | None = None,
261
+ actions: AsyncEventBusActions[Any] | None = None,
262
+ on_before: Callable[[Any], Awaitable[None]] | None = None,
263
+ on_after: Callable[[Any, PublishResult], Awaitable[None]] | None = None,
264
+ on_error: Callable[[EventErrorContext[Any]], Awaitable[None]] | None = None,
265
+ on_timeout: Callable[[EventTimeoutContext[Any]], Awaitable[None]] | None = None,
266
+ ) -> None:
267
+ self._history = _validate_history_capacity(history)
268
+ self._error_policy = ErrorPolicy(error_policy)
269
+ self._delivery_mode = DeliveryMode(delivery_mode)
270
+ self._max_concurrency = (
271
+ None
272
+ if max_concurrency is None
273
+ else validate_positive_capacity(
274
+ max_concurrency,
275
+ name="Maximum concurrent deliveries",
276
+ )
277
+ )
278
+ if self._max_concurrency is not None and self._delivery_mode is not DeliveryMode.CONCURRENT:
279
+ raise ValueError("max_concurrency requires DeliveryMode.CONCURRENT")
280
+ direct_actions = (on_before, on_after, on_error, on_timeout)
281
+ if actions is not None and any(action is not None for action in direct_actions):
282
+ raise ValueError("Use actions or direct on_* callbacks, not both")
283
+ self._actions = actions or AsyncEventBusActions(
284
+ on_before=on_before,
285
+ on_after=on_after,
286
+ on_error=on_error,
287
+ on_timeout=on_timeout,
288
+ )
289
+ self._channels: dict[str, AsyncEventBus[Any]] = {}
290
+ self._pending_close: dict[int, AsyncEventBus[Any]] = {}
291
+ self._lock = threading.RLock()
292
+ self._running = True
293
+
294
+ @property
295
+ def running(self) -> bool:
296
+ with self._lock:
297
+ return self._running
298
+
299
+ @property
300
+ def channel_names(self) -> tuple[str, ...]:
301
+ with self._lock:
302
+ return tuple(sorted(self._channels))
303
+
304
+ def channel(self, name: str) -> AsyncEventBus[Any]:
305
+ name = _channel_name(name)
306
+ with self._lock:
307
+ bus = self._channels.get(name)
308
+ if bus is None:
309
+ bus = AsyncEventBus(
310
+ history=self._history,
311
+ error_policy=self._error_policy,
312
+ delivery_mode=self._delivery_mode,
313
+ max_concurrency=self._max_concurrency,
314
+ actions=self._actions,
315
+ )
316
+ self._channels[name] = bus
317
+ self._running = True
318
+ return bus
319
+
320
+ def add(self, name: str, bus: AsyncEventBus[Any]) -> AsyncEventBus[Any]:
321
+ """Register a bus owned by this engine under a channel name.
322
+
323
+ An open bus cannot be claimed by another engine; that registration raises
324
+ ValueError.
325
+ """
326
+
327
+ if not isinstance(bus, AsyncEventBus):
328
+ raise TypeError("Async event engine channels must be AsyncEventBus instances")
329
+ if bus.closed:
330
+ raise EventBusClosedError("Cannot add a closed AsyncEventBus to an engine")
331
+ name = _channel_name(name)
332
+ with self._lock:
333
+ if name in self._channels:
334
+ raise ValueError(f"Async event channel already exists: {name}")
335
+ if any(channel is bus for channel in self._channels.values()) or any(
336
+ channel is bus for channel in self._pending_close.values()
337
+ ):
338
+ raise ValueError("An AsyncEventBus instance can belong to only one channel")
339
+ self._channels[name] = bus
340
+ if not bus._claim_engine_ownership(self):
341
+ del self._channels[name]
342
+ raise ValueError("An AsyncEventBus instance can belong to only one engine")
343
+ self._running = True
344
+ return bus
345
+
346
+ async def publish(self, channel: str, event: Any) -> PublishResult:
347
+ return await self.channel(channel).publish(event)
348
+
349
+ async def subscribe(
350
+ self,
351
+ channel: str,
352
+ callback: Callable[[Any], Awaitable[None]],
353
+ *,
354
+ event_filter: EventFilter[Any] | None = None,
355
+ event_type: EventType | None = None,
356
+ predicate: Callable[[Any], bool] | None = None,
357
+ once: bool = False,
358
+ times: int | None = None,
359
+ replay: int = 0,
360
+ ) -> AsyncSubscription:
361
+ return await self.channel(channel).subscribe(
362
+ callback,
363
+ event_filter=event_filter,
364
+ event_type=event_type,
365
+ predicate=predicate,
366
+ once=once,
367
+ times=times,
368
+ replay=replay,
369
+ )
370
+
371
+ async def once(
372
+ self,
373
+ channel: str,
374
+ callback: Callable[[Any], Awaitable[None]],
375
+ *,
376
+ event_filter: EventFilter[Any] | None = None,
377
+ event_type: EventType | None = None,
378
+ predicate: Callable[[Any], bool] | None = None,
379
+ replay: int = 0,
380
+ ) -> AsyncSubscription:
381
+ return await self.channel(channel).once(
382
+ callback,
383
+ event_filter=event_filter,
384
+ event_type=event_type,
385
+ predicate=predicate,
386
+ replay=replay,
387
+ )
388
+
389
+ async def publish_every(
390
+ self,
391
+ channel: str,
392
+ event: Any,
393
+ interval: float,
394
+ *,
395
+ times: int | None = None,
396
+ immediately: bool = True,
397
+ ) -> AsyncSubscription:
398
+ return await self.channel(channel).publish_every(
399
+ event,
400
+ interval,
401
+ times=times,
402
+ immediately=immediately,
403
+ )
404
+
405
+ async def wait_for(
406
+ self,
407
+ channel: str,
408
+ *,
409
+ event_filter: EventFilter[Any] | None = None,
410
+ event_type: EventType | None = None,
411
+ predicate: Callable[[Any], bool] | None = None,
412
+ timeout: float | None = None,
413
+ ) -> Any | None:
414
+ return await self.channel(channel).wait_for(
415
+ event_filter=event_filter,
416
+ event_type=event_type,
417
+ predicate=predicate,
418
+ timeout=timeout,
419
+ )
420
+
421
+ async def remove(self, name: str) -> bool:
422
+ name = _channel_name(name)
423
+ with self._lock:
424
+ bus = self._channels.pop(name, None)
425
+ if bus is not None:
426
+ self._pending_close[id(bus)] = bus
427
+ if bus is None:
428
+ return False
429
+ await bus.close()
430
+ with self._lock:
431
+ self._pending_close.pop(id(bus), None)
432
+ return True
433
+
434
+ async def start(self) -> None:
435
+ """Keep lifecycle symmetry; channels bind to a loop when first used."""
436
+
437
+ with self._lock:
438
+ self._running = True
439
+
440
+ async def stop(self) -> None:
441
+ with self._lock:
442
+ channels = tuple(self._channels.values())
443
+ self._channels.clear()
444
+ for channel in channels:
445
+ self._pending_close[id(channel)] = channel
446
+ pending = tuple(self._pending_close.values())
447
+ self._running = False
448
+ errors: list[Exception] = []
449
+ for channel in pending:
450
+ try:
451
+ await channel.close()
452
+ except Exception as exc:
453
+ errors.append(exc)
454
+ else:
455
+ with self._lock:
456
+ self._pending_close.pop(id(channel), None)
457
+ if errors:
458
+ raise ExceptionGroup("One or more async event channels failed to close", errors)
459
+
460
+ close = stop
461
+
462
+ async def __aenter__(self) -> "AsyncEventEngine":
463
+ return self
464
+
465
+ async def __aexit__(self, *_args: object) -> None:
466
+ await self.stop()
eventflow/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Errors raised by the in-process event buses."""
2
+
3
+
4
+ class EventBusError(RuntimeError):
5
+ """Base error for event bus operations."""
6
+
7
+
8
+ class EventBusClosedError(EventBusError):
9
+ """Raised when an operation requires an open event bus."""
10
+
11
+
12
+ class InvalidEventHandlerError(EventBusError, TypeError):
13
+ """Raised when a handler is not compatible with the selected bus."""
14
+
15
+
16
+ class CallbackTimeoutError(EventBusError, TimeoutError):
17
+ """Raised when a callback exceeds its configured execution budget."""
18
+
19
+
20
+ class EventShutdownTimeoutError(EventBusError, TimeoutError):
21
+ """Raised when a synchronous schedule cannot stop within its budget."""