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/base.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Common contracts and pure helpers shared by both event buses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
import weakref
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from .contracts import ErrorPolicy, EventBusStats
|
|
14
|
+
from .errors import EventBusClosedError
|
|
15
|
+
from .filtering import EventFilter, EventType, coerce_event_filter
|
|
16
|
+
from .history import EventHistory, MemoryEventHistory, _validate_query_limit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_positive_capacity(value: int, *, name: str) -> int:
|
|
23
|
+
"""Validate a bounded collection/schedule capacity."""
|
|
24
|
+
|
|
25
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
26
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_replay_capacity(value: int) -> int:
|
|
31
|
+
"""Validate a replay buffer capacity as a positive integer."""
|
|
32
|
+
|
|
33
|
+
return validate_positive_capacity(value, name="Replay buffer limit")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_positive_timeout(value: float, *, name: str) -> float:
|
|
37
|
+
"""Validate and normalize a finite timeout value."""
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
isinstance(value, bool)
|
|
41
|
+
or not isinstance(value, (int, float))
|
|
42
|
+
or not math.isfinite(value)
|
|
43
|
+
or value <= 0
|
|
44
|
+
):
|
|
45
|
+
raise ValueError(f"{name} must be positive and finite")
|
|
46
|
+
return float(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_nonnegative_count(value: int, *, name: str) -> int:
|
|
50
|
+
"""Validate a count that may be zero."""
|
|
51
|
+
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
53
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_nonnegative_timeout(value: float, *, name: str) -> float:
|
|
58
|
+
"""Validate and normalize a finite timeout that may be zero."""
|
|
59
|
+
|
|
60
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
61
|
+
raise ValueError(f"{name} must be non-negative and finite")
|
|
62
|
+
try:
|
|
63
|
+
normalized = float(value)
|
|
64
|
+
except OverflowError as exc:
|
|
65
|
+
raise ValueError(f"{name} must be non-negative and finite") from exc
|
|
66
|
+
if not math.isfinite(normalized) or normalized < 0:
|
|
67
|
+
raise ValueError(f"{name} must be non-negative and finite")
|
|
68
|
+
return normalized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BaseEventBus(Generic[T], ABC):
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
history: EventHistory[T] | int | None = None,
|
|
76
|
+
error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
|
|
77
|
+
) -> None:
|
|
78
|
+
if history is None or isinstance(history, EventHistory):
|
|
79
|
+
self._history = history
|
|
80
|
+
elif isinstance(history, bool) or not isinstance(history, int):
|
|
81
|
+
raise TypeError("Event history must be a positive capacity or EventHistory")
|
|
82
|
+
else:
|
|
83
|
+
self._history = MemoryEventHistory[T](history)
|
|
84
|
+
self._error_policy = ErrorPolicy(error_policy)
|
|
85
|
+
self._engine_owner_lock = threading.Lock()
|
|
86
|
+
self._engine_owner: weakref.ReferenceType[Any] | None = None
|
|
87
|
+
self._published = 0
|
|
88
|
+
self._delivered = 0
|
|
89
|
+
self._failed = 0
|
|
90
|
+
|
|
91
|
+
def _claim_engine_ownership(self, owner: Any) -> bool:
|
|
92
|
+
"""Atomically assign this bus to one live engine without retaining it."""
|
|
93
|
+
with self._engine_owner_lock:
|
|
94
|
+
current_owner = self._engine_owner() if self._engine_owner is not None else None
|
|
95
|
+
if current_owner is not None and current_owner is not owner:
|
|
96
|
+
return False
|
|
97
|
+
self._engine_owner = weakref.ref(owner)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def history(self) -> EventHistory[T] | None:
|
|
102
|
+
return self._history
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def error_policy(self) -> ErrorPolicy:
|
|
106
|
+
return self._error_policy
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def closed(self) -> bool: ...
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def subscriber_count(self) -> int: ...
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def stats(self) -> EventBusStats: ...
|
|
119
|
+
|
|
120
|
+
def _ensure_open(self) -> None:
|
|
121
|
+
if self.closed:
|
|
122
|
+
raise EventBusClosedError("Event bus is closed")
|
|
123
|
+
|
|
124
|
+
def _record(self, event: T) -> None:
|
|
125
|
+
if self._history is not None:
|
|
126
|
+
self._history.append(event)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _delivery_limit(*, once: bool, times: int | None) -> int | None:
|
|
130
|
+
if times is not None:
|
|
131
|
+
if isinstance(times, bool) or not isinstance(times, int) or times <= 0:
|
|
132
|
+
raise ValueError("Subscription times must be a positive integer")
|
|
133
|
+
if once and times != 1:
|
|
134
|
+
raise ValueError("Use once=True or times, not both")
|
|
135
|
+
return times
|
|
136
|
+
return 1 if once else None
|
|
137
|
+
|
|
138
|
+
def latest(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
event_filter: EventFilter[T] | None = None,
|
|
142
|
+
event_type: EventType | None = None,
|
|
143
|
+
predicate: Callable[[T], bool] | None = None,
|
|
144
|
+
) -> T | None:
|
|
145
|
+
normalized_filter = coerce_event_filter(
|
|
146
|
+
event_filter,
|
|
147
|
+
event_type=event_type,
|
|
148
|
+
predicate=predicate,
|
|
149
|
+
)
|
|
150
|
+
if self._history is None:
|
|
151
|
+
return None
|
|
152
|
+
if event_filter is None and event_type is None and predicate is None:
|
|
153
|
+
return self._history.latest()
|
|
154
|
+
return self._history.latest(normalized_filter)
|
|
155
|
+
|
|
156
|
+
def query(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
event_filter: EventFilter[T] | None = None,
|
|
160
|
+
event_type: EventType | None = None,
|
|
161
|
+
predicate: Callable[[T], bool] | None = None,
|
|
162
|
+
limit: int | None = None,
|
|
163
|
+
) -> tuple[T, ...]:
|
|
164
|
+
_validate_query_limit(limit)
|
|
165
|
+
normalized_filter = coerce_event_filter(
|
|
166
|
+
event_filter,
|
|
167
|
+
event_type=event_type,
|
|
168
|
+
predicate=predicate,
|
|
169
|
+
)
|
|
170
|
+
if self._history is None:
|
|
171
|
+
return ()
|
|
172
|
+
if event_filter is None and event_type is None and predicate is None:
|
|
173
|
+
return self._history.query(limit=limit)
|
|
174
|
+
return self._history.query(normalized_filter, limit=limit)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _is_async_handler(handler: Any) -> bool:
|
|
178
|
+
if inspect.iscoroutinefunction(handler):
|
|
179
|
+
return True
|
|
180
|
+
return callable(handler) and inspect.iscoroutinefunction(type(handler).__call__)
|
eventflow/callback.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Configurable callback registrations, independent of transport and event bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._callbacks import synchronous_result
|
|
16
|
+
from .compatibility import ExceptionGroup
|
|
17
|
+
from .errors import CallbackTimeoutError
|
|
18
|
+
from ._lifecycle import finish_cleanup
|
|
19
|
+
from .subscription import Subscription
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _cancel_task(task: asyncio.Future[Any]) -> None:
|
|
23
|
+
task.cancel()
|
|
24
|
+
await finish_cleanup(asyncio.gather(task, return_exceptions=True))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class CallbackContext:
|
|
29
|
+
event: Any
|
|
30
|
+
result: Any = None
|
|
31
|
+
error: BaseException | None = None
|
|
32
|
+
elapsed: float = 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CallbackSubscription(Subscription):
|
|
36
|
+
"""Callable decorator result with execution policy and per-registration hooks.
|
|
37
|
+
|
|
38
|
+
Predicates run at delivery time. Rate-limited/disabled/filtered events do
|
|
39
|
+
not consume max_calls. Sync timeout is observational, never thread killing.
|
|
40
|
+
Hooks receive CallbackContext and run outside the registration lock.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
identifier: int,
|
|
46
|
+
cancel: Callable[[], None],
|
|
47
|
+
callback: Callable,
|
|
48
|
+
*,
|
|
49
|
+
asynchronous: bool = False,
|
|
50
|
+
once: bool = False,
|
|
51
|
+
max_calls: int | None = None,
|
|
52
|
+
frequency_hz: float | None = None,
|
|
53
|
+
timeout: float | None = None,
|
|
54
|
+
predicate: Callable | None = None,
|
|
55
|
+
enabled: bool = True,
|
|
56
|
+
on_before: Callable | None = None,
|
|
57
|
+
on_success: Callable | None = None,
|
|
58
|
+
on_error: Callable | None = None,
|
|
59
|
+
on_timeout: Callable | None = None,
|
|
60
|
+
on_after: Callable | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(identifier, cancel)
|
|
63
|
+
if once and max_calls is not None:
|
|
64
|
+
raise ValueError("once and max_calls cannot be combined")
|
|
65
|
+
if max_calls is not None and (
|
|
66
|
+
isinstance(max_calls, bool) or not isinstance(max_calls, int) or max_calls < 1
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("max_calls must be a positive integer")
|
|
69
|
+
for value in (frequency_hz, timeout):
|
|
70
|
+
if value is not None and (
|
|
71
|
+
isinstance(value, bool) or not math.isfinite(value) or value <= 0
|
|
72
|
+
):
|
|
73
|
+
raise ValueError("frequency_hz and timeout must be positive and finite")
|
|
74
|
+
if not isinstance(enabled, bool):
|
|
75
|
+
raise TypeError("enabled must be a bool")
|
|
76
|
+
if predicate is not None and (
|
|
77
|
+
not callable(predicate) or inspect.iscoroutinefunction(predicate)
|
|
78
|
+
):
|
|
79
|
+
raise TypeError("predicate must be a synchronous callable")
|
|
80
|
+
self.callback = callback
|
|
81
|
+
self.asynchronous = asynchronous
|
|
82
|
+
self.timeout = timeout
|
|
83
|
+
self.predicate = predicate
|
|
84
|
+
self._enabled = enabled
|
|
85
|
+
self._maximum = 1 if once else max_calls
|
|
86
|
+
self._interval = 0.0 if frequency_hz is None else 1.0 / frequency_hz
|
|
87
|
+
self._last = float("-inf")
|
|
88
|
+
self._calls = 0
|
|
89
|
+
self._policy_lock = threading.RLock()
|
|
90
|
+
self._hooks: dict[str, list[Callable]] = {}
|
|
91
|
+
for name, hook in (
|
|
92
|
+
("before", on_before),
|
|
93
|
+
("success", on_success),
|
|
94
|
+
("error", on_error),
|
|
95
|
+
("timeout", on_timeout),
|
|
96
|
+
("after", on_after),
|
|
97
|
+
):
|
|
98
|
+
if hook is not None:
|
|
99
|
+
self._hook(name, hook)
|
|
100
|
+
|
|
101
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
102
|
+
return self.callback(*args, **kwargs)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def enabled(self) -> bool:
|
|
106
|
+
with self._policy_lock:
|
|
107
|
+
return self._enabled
|
|
108
|
+
|
|
109
|
+
def enable(self) -> None:
|
|
110
|
+
with self._policy_lock:
|
|
111
|
+
self._enabled = True
|
|
112
|
+
|
|
113
|
+
def disable(self) -> None:
|
|
114
|
+
with self._policy_lock:
|
|
115
|
+
self._enabled = False
|
|
116
|
+
|
|
117
|
+
def _hook(self, name: str, callback: Callable) -> Callable:
|
|
118
|
+
if not callable(callback):
|
|
119
|
+
raise TypeError("Hook must be callable")
|
|
120
|
+
asynchronous = inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction(
|
|
121
|
+
type(callback).__call__
|
|
122
|
+
)
|
|
123
|
+
if asynchronous != self.asynchronous:
|
|
124
|
+
raise TypeError("Hook must match callback sync/async mode")
|
|
125
|
+
with self._policy_lock:
|
|
126
|
+
self._hooks.setdefault(name, []).append(callback)
|
|
127
|
+
return callback
|
|
128
|
+
|
|
129
|
+
def on_before(self, callback: Callable) -> Callable:
|
|
130
|
+
return self._hook("before", callback)
|
|
131
|
+
|
|
132
|
+
def on_success(self, callback: Callable) -> Callable:
|
|
133
|
+
return self._hook("success", callback)
|
|
134
|
+
|
|
135
|
+
def on_error(self, callback: Callable) -> Callable:
|
|
136
|
+
return self._hook("error", callback)
|
|
137
|
+
|
|
138
|
+
def on_timeout(self, callback: Callable) -> Callable:
|
|
139
|
+
return self._hook("timeout", callback)
|
|
140
|
+
|
|
141
|
+
def on_after(self, callback: Callable) -> Callable:
|
|
142
|
+
return self._hook("after", callback)
|
|
143
|
+
|
|
144
|
+
def _accept(self, event: Any) -> bool:
|
|
145
|
+
if not self.active or not self.enabled:
|
|
146
|
+
return False
|
|
147
|
+
if self.predicate is not None:
|
|
148
|
+
accepted = self.predicate(event)
|
|
149
|
+
if inspect.isawaitable(accepted):
|
|
150
|
+
if inspect.iscoroutine(accepted):
|
|
151
|
+
accepted.close()
|
|
152
|
+
raise TypeError("predicate returned an awaitable")
|
|
153
|
+
if not isinstance(accepted, bool):
|
|
154
|
+
raise TypeError("predicate must return bool")
|
|
155
|
+
if not accepted:
|
|
156
|
+
return False
|
|
157
|
+
with self._policy_lock:
|
|
158
|
+
now = time.monotonic()
|
|
159
|
+
if not self.active or not self._enabled or now - self._last < self._interval:
|
|
160
|
+
return False
|
|
161
|
+
if self._maximum is not None and self._calls >= self._maximum:
|
|
162
|
+
return False
|
|
163
|
+
self._calls += 1
|
|
164
|
+
self._last = now
|
|
165
|
+
exhausted = self._calls == self._maximum
|
|
166
|
+
if exhausted:
|
|
167
|
+
self.cancel()
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _snapshot(self, name: str) -> tuple[Callable, ...]:
|
|
171
|
+
with self._policy_lock:
|
|
172
|
+
return tuple(self._hooks.get(name, ()))
|
|
173
|
+
|
|
174
|
+
def _run_hooks(self, name: str, context: CallbackContext) -> None:
|
|
175
|
+
errors = []
|
|
176
|
+
for hook in self._snapshot(name):
|
|
177
|
+
try:
|
|
178
|
+
synchronous_result(hook(context))
|
|
179
|
+
except Exception as error:
|
|
180
|
+
errors.append(error)
|
|
181
|
+
self._raise_errors(errors)
|
|
182
|
+
|
|
183
|
+
async def _run_hooks_async(self, name: str, context: CallbackContext) -> None:
|
|
184
|
+
errors = []
|
|
185
|
+
for hook in self._snapshot(name):
|
|
186
|
+
try:
|
|
187
|
+
await hook(context)
|
|
188
|
+
except Exception as error:
|
|
189
|
+
errors.append(error)
|
|
190
|
+
self._raise_errors(errors)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _raise_errors(errors: list[Exception]) -> None:
|
|
194
|
+
if len(errors) == 1:
|
|
195
|
+
raise errors[0]
|
|
196
|
+
if errors:
|
|
197
|
+
raise ExceptionGroup("Callback and hook failures", errors)
|
|
198
|
+
|
|
199
|
+
def _report_failure(self, name: str, context: CallbackContext, error: Exception) -> None:
|
|
200
|
+
context.error = error
|
|
201
|
+
try:
|
|
202
|
+
self._run_hooks(name, context)
|
|
203
|
+
except Exception as hook_error:
|
|
204
|
+
raise ExceptionGroup("Callback and error hook failed", [error, hook_error]) from None
|
|
205
|
+
|
|
206
|
+
async def _report_failure_async(
|
|
207
|
+
self, name: str, context: CallbackContext, error: Exception
|
|
208
|
+
) -> None:
|
|
209
|
+
context.error = error
|
|
210
|
+
try:
|
|
211
|
+
await self._run_hooks_async(name, context)
|
|
212
|
+
except Exception as hook_error:
|
|
213
|
+
raise ExceptionGroup("Callback and error hook failed", [error, hook_error]) from None
|
|
214
|
+
|
|
215
|
+
def invoke(self, event: Any) -> Any:
|
|
216
|
+
context = CallbackContext(event)
|
|
217
|
+
started = time.monotonic()
|
|
218
|
+
try:
|
|
219
|
+
if not self._accept(event):
|
|
220
|
+
return None
|
|
221
|
+
except Exception as error:
|
|
222
|
+
self._report_failure("error", context, error)
|
|
223
|
+
raise
|
|
224
|
+
try:
|
|
225
|
+
self._run_hooks("before", context)
|
|
226
|
+
context.result = self.callback(event)
|
|
227
|
+
if inspect.isawaitable(context.result):
|
|
228
|
+
if inspect.iscoroutine(context.result):
|
|
229
|
+
context.result.close()
|
|
230
|
+
raise TypeError("Sync callback returned an awaitable")
|
|
231
|
+
context.elapsed = time.monotonic() - started
|
|
232
|
+
if self.timeout is not None and context.elapsed > self.timeout:
|
|
233
|
+
raise CallbackTimeoutError("Callback exceeded its synchronous time budget")
|
|
234
|
+
self._run_hooks("success", context)
|
|
235
|
+
except Exception as error:
|
|
236
|
+
self._report_failure(
|
|
237
|
+
"timeout" if isinstance(error, CallbackTimeoutError) else "error", context, error
|
|
238
|
+
)
|
|
239
|
+
raise
|
|
240
|
+
else:
|
|
241
|
+
return context.result
|
|
242
|
+
finally:
|
|
243
|
+
context.elapsed = time.monotonic() - started
|
|
244
|
+
pending = sys.exc_info()[1]
|
|
245
|
+
try:
|
|
246
|
+
self._run_hooks("after", context)
|
|
247
|
+
except Exception as error:
|
|
248
|
+
failures = [pending] if isinstance(pending, Exception) else []
|
|
249
|
+
try:
|
|
250
|
+
self._report_failure("error", context, error)
|
|
251
|
+
except Exception as report_error:
|
|
252
|
+
failures.append(report_error)
|
|
253
|
+
else:
|
|
254
|
+
failures.append(error)
|
|
255
|
+
if pending is not None and not isinstance(pending, Exception):
|
|
256
|
+
raise pending from ExceptionGroup("Cleanup failed", failures)
|
|
257
|
+
self._raise_errors(failures)
|
|
258
|
+
|
|
259
|
+
async def invoke_async(self, event: Any) -> Any:
|
|
260
|
+
context = CallbackContext(event)
|
|
261
|
+
started = time.monotonic()
|
|
262
|
+
try:
|
|
263
|
+
if not self._accept(event):
|
|
264
|
+
return None
|
|
265
|
+
except Exception as error:
|
|
266
|
+
await self._report_failure_async("error", context, error)
|
|
267
|
+
raise
|
|
268
|
+
try:
|
|
269
|
+
await self._run_hooks_async("before", context)
|
|
270
|
+
operation = self.callback(event)
|
|
271
|
+
if self.timeout is None:
|
|
272
|
+
context.result = await operation
|
|
273
|
+
else:
|
|
274
|
+
remaining = self.timeout - (time.monotonic() - started)
|
|
275
|
+
if remaining <= 0:
|
|
276
|
+
if inspect.iscoroutine(operation):
|
|
277
|
+
operation.close()
|
|
278
|
+
else:
|
|
279
|
+
await _cancel_task(asyncio.ensure_future(operation))
|
|
280
|
+
raise CallbackTimeoutError("Callback exceeded its asynchronous time budget")
|
|
281
|
+
task = asyncio.ensure_future(operation)
|
|
282
|
+
try:
|
|
283
|
+
done, _ = await asyncio.wait({task}, timeout=remaining)
|
|
284
|
+
except asyncio.CancelledError:
|
|
285
|
+
await _cancel_task(task)
|
|
286
|
+
raise
|
|
287
|
+
if task not in done:
|
|
288
|
+
await _cancel_task(task)
|
|
289
|
+
raise CallbackTimeoutError("Callback exceeded its asynchronous time budget")
|
|
290
|
+
context.result = await task
|
|
291
|
+
context.elapsed = time.monotonic() - started
|
|
292
|
+
await self._run_hooks_async("success", context)
|
|
293
|
+
except asyncio.CancelledError as error:
|
|
294
|
+
context.error = error
|
|
295
|
+
raise
|
|
296
|
+
except Exception as error:
|
|
297
|
+
await self._report_failure_async(
|
|
298
|
+
"timeout" if isinstance(error, CallbackTimeoutError) else "error", context, error
|
|
299
|
+
)
|
|
300
|
+
raise
|
|
301
|
+
else:
|
|
302
|
+
return context.result
|
|
303
|
+
finally:
|
|
304
|
+
context.elapsed = time.monotonic() - started
|
|
305
|
+
pending = sys.exc_info()[1]
|
|
306
|
+
try:
|
|
307
|
+
await self._run_hooks_async("after", context)
|
|
308
|
+
except Exception as error:
|
|
309
|
+
failures = [pending] if isinstance(pending, Exception) else []
|
|
310
|
+
try:
|
|
311
|
+
await self._report_failure_async("error", context, error)
|
|
312
|
+
except Exception as report_error:
|
|
313
|
+
failures.append(report_error)
|
|
314
|
+
else:
|
|
315
|
+
failures.append(error)
|
|
316
|
+
if pending is not None and not isinstance(pending, Exception):
|
|
317
|
+
raise pending from ExceptionGroup("Cleanup failed", failures)
|
|
318
|
+
self._raise_errors(failures)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Expose standard-library features across supported Python versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
from builtins import BaseExceptionGroup, ExceptionGroup
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
else: # pragma: no cover - exercised by the Python 3.10 environment
|
|
11
|
+
from backports.strenum import StrEnum
|
|
12
|
+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
|
|
13
|
+
|
|
14
|
+
__all__ = ["BaseExceptionGroup", "ExceptionGroup", "StrEnum"]
|
eventflow/context.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Optional immutable event envelopes and task-local correlation identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Any, Generic, TypeVar
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
_correlation: ContextVar[str | None] = ContextVar("eventflow_correlation", default=None)
|
|
17
|
+
__all__ = ["EventEnvelope", "correlation_context", "current_correlation_id"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def current_correlation_id() -> str | None:
|
|
21
|
+
"""Return the identifier in the current context, if one was bound."""
|
|
22
|
+
return _correlation.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ReadOnlyMetadata(Mapping[str, object]):
|
|
26
|
+
"""Copied read-only metadata that supports deepcopy and pickle."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("_mapping",)
|
|
29
|
+
|
|
30
|
+
def __init__(self, values: Mapping[str, object]) -> None:
|
|
31
|
+
self._mapping = MappingProxyType(dict(values))
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, key: str) -> object:
|
|
34
|
+
return self._mapping[key]
|
|
35
|
+
|
|
36
|
+
def __iter__(self) -> Iterator[str]:
|
|
37
|
+
return iter(self._mapping)
|
|
38
|
+
|
|
39
|
+
def __len__(self) -> int:
|
|
40
|
+
return len(self._mapping)
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"mappingproxy({dict(self._mapping)!r})"
|
|
44
|
+
|
|
45
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, object]:
|
|
46
|
+
return deepcopy(dict(self._mapping), memo)
|
|
47
|
+
|
|
48
|
+
def __reduce__(self) -> tuple[type[_ReadOnlyMetadata], tuple[dict[str, object]]]:
|
|
49
|
+
return type(self), (dict(self._mapping),)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def correlation_context(correlation_id: str) -> Iterator[str]:
|
|
54
|
+
"""Bind an identifier until exit; nested and async task contexts are isolated."""
|
|
55
|
+
if not isinstance(correlation_id, str) or not correlation_id.strip():
|
|
56
|
+
raise ValueError("Correlation ID must be a non-empty string")
|
|
57
|
+
token = _correlation.set(correlation_id)
|
|
58
|
+
try:
|
|
59
|
+
yield correlation_id
|
|
60
|
+
finally:
|
|
61
|
+
_correlation.reset(token)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class EventEnvelope(Generic[T]):
|
|
66
|
+
"""Opt-in event metadata; metadata is copied and shallowly read-only.
|
|
67
|
+
|
|
68
|
+
The payload and nested metadata values retain their original mutability.
|
|
69
|
+
A missing correlation ID inherits the current context or the envelope ID.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
event: T
|
|
73
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
74
|
+
correlation_id: str | None = None
|
|
75
|
+
source: str | None = None
|
|
76
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
77
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if not isinstance(self.id, str) or not self.id.strip():
|
|
81
|
+
raise ValueError("Envelope ID must be a non-empty string")
|
|
82
|
+
correlation = self.correlation_id
|
|
83
|
+
if correlation is None:
|
|
84
|
+
correlation = current_correlation_id() or self.id
|
|
85
|
+
if not isinstance(correlation, str) or not correlation.strip():
|
|
86
|
+
raise ValueError("Correlation ID must be a non-empty string")
|
|
87
|
+
if not isinstance(self.timestamp, datetime) or self.timestamp.utcoffset() is None:
|
|
88
|
+
raise ValueError("Envelope timestamp must be timezone-aware")
|
|
89
|
+
object.__setattr__(self, "timestamp", self.timestamp.astimezone(timezone.utc))
|
|
90
|
+
object.__setattr__(self, "correlation_id", correlation)
|
|
91
|
+
object.__setattr__(self, "metadata", _ReadOnlyMetadata(self.metadata))
|
|
92
|
+
|
|
93
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> EventEnvelope[T]:
|
|
94
|
+
existing = memo.get(id(self))
|
|
95
|
+
if existing is not None:
|
|
96
|
+
return existing
|
|
97
|
+
copied = type(self).__new__(type(self))
|
|
98
|
+
memo[id(self)] = copied
|
|
99
|
+
for name in ("event", "id", "correlation_id", "source", "timestamp"):
|
|
100
|
+
object.__setattr__(copied, name, deepcopy(getattr(self, name), memo))
|
|
101
|
+
metadata = deepcopy(dict(self.metadata), memo)
|
|
102
|
+
object.__setattr__(copied, "metadata", _ReadOnlyMetadata(metadata))
|
|
103
|
+
return copied
|
eventflow/contracts.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Small shared contracts used by synchronous and asynchronous event buses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .compatibility import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ErrorPolicy(StrEnum):
|
|
11
|
+
"""Define how subscriber failures are reported by publish operations."""
|
|
12
|
+
|
|
13
|
+
ISOLATE = "isolate"
|
|
14
|
+
RAISE = "raise"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeliveryMode(StrEnum):
|
|
18
|
+
"""Define ordering for asynchronous subscriber delivery."""
|
|
19
|
+
|
|
20
|
+
SEQUENTIAL = "sequential"
|
|
21
|
+
CONCURRENT = "concurrent"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class PublishResult:
|
|
26
|
+
matched: int = 0
|
|
27
|
+
delivered: int = 0
|
|
28
|
+
failed: int = 0
|
|
29
|
+
errors: tuple[Exception, ...] = ()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def successful(self) -> bool:
|
|
33
|
+
return self.failed == 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class EventBusStats:
|
|
38
|
+
published: int = 0
|
|
39
|
+
delivered: int = 0
|
|
40
|
+
failed: int = 0
|