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/history.py ADDED
@@ -0,0 +1,111 @@
1
+ """Optional bounded in-memory history for event buses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from abc import ABC, abstractmethod
7
+ from collections import deque
8
+ from itertools import islice
9
+ from typing import Generic, TypeVar
10
+
11
+ from .filtering import EventFilter
12
+
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ def _validate_query_limit(limit: int | None) -> None:
18
+ if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0):
19
+ raise ValueError("Event history query limit must be a positive integer")
20
+
21
+
22
+ class EventHistory(Generic[T], ABC):
23
+ """Storage contract for custom histories used by event buses.
24
+
25
+ Implementations must make ``append``, ``latest``, ``query``, ``clear``, and
26
+ ``__len__`` safe under concurrent calls and return query results from a
27
+ consistent snapshot. A history shared by multiple buses must provide the
28
+ same guarantees across those buses; a bus lock only protects its own writes.
29
+ """
30
+
31
+ @abstractmethod
32
+ def append(self, event: T) -> None: ...
33
+
34
+ @abstractmethod
35
+ def latest(self, event_filter: EventFilter[T] | None = None) -> T | None: ...
36
+
37
+ @abstractmethod
38
+ def query(
39
+ self,
40
+ event_filter: EventFilter[T] | None = None,
41
+ *,
42
+ limit: int | None = None,
43
+ ) -> tuple[T, ...]: ...
44
+
45
+ @abstractmethod
46
+ def clear(self) -> None: ...
47
+
48
+ @abstractmethod
49
+ def __len__(self) -> int: ...
50
+
51
+
52
+ class MemoryEventHistory(EventHistory[T]):
53
+ """Thread-safe bounded history preserving publication order."""
54
+
55
+ def __init__(self, capacity: int = 1_000) -> None:
56
+ if isinstance(capacity, bool) or not isinstance(capacity, int) or capacity <= 0:
57
+ raise ValueError("Event history capacity must be a positive integer")
58
+ self._events: deque[T] = deque(maxlen=capacity)
59
+ self._lock = threading.RLock()
60
+
61
+ @property
62
+ def capacity(self) -> int:
63
+ return self._events.maxlen or 0
64
+
65
+ def append(self, event: T) -> None:
66
+ with self._lock:
67
+ self._events.append(event)
68
+
69
+ def latest(self, event_filter: EventFilter[T] | None = None) -> T | None:
70
+ with self._lock:
71
+ if event_filter is None:
72
+ return self._events[-1] if self._events else None
73
+ events = tuple(self._events)
74
+ return next(
75
+ (event for event in reversed(events) if event_filter.matches(event)),
76
+ None,
77
+ )
78
+
79
+ def query(
80
+ self,
81
+ event_filter: EventFilter[T] | None = None,
82
+ *,
83
+ limit: int | None = None,
84
+ ) -> tuple[T, ...]:
85
+ _validate_query_limit(limit)
86
+ with self._lock:
87
+ if event_filter is None:
88
+ if limit is None:
89
+ return tuple(self._events)
90
+ recent = list(islice(reversed(self._events), limit))
91
+ recent.reverse()
92
+ return tuple(recent)
93
+ events = tuple(self._events)
94
+ if limit is None:
95
+ return tuple(event for event in events if event_filter.matches(event))
96
+ matched: list[T] = []
97
+ for event in reversed(events):
98
+ if event_filter.matches(event):
99
+ matched.append(event)
100
+ if len(matched) == limit:
101
+ break
102
+ matched.reverse()
103
+ return tuple(matched)
104
+
105
+ def clear(self) -> None:
106
+ with self._lock:
107
+ self._events.clear()
108
+
109
+ def __len__(self) -> int:
110
+ with self._lock:
111
+ return len(self._events)
@@ -0,0 +1,133 @@
1
+ """Opt-in pipeline measurements; no logging setup or global collectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from types import MappingProxyType
9
+ from typing import Literal, Protocol
10
+
11
+ ObservationKind = Literal["run", "stage", "middleware"]
12
+ ExecutionStatus = Literal["success", "error", "cancelled"]
13
+ __all__ = [
14
+ "ExecutionMetrics",
15
+ "PipelineObservation",
16
+ "PipelineObserver",
17
+ "PipelineStats",
18
+ "PipelineStatsSnapshot",
19
+ "ExecutionStatus",
20
+ "ObservationKind",
21
+ ]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PipelineObservation:
26
+ """One completed execution; elapsed time is measured in seconds."""
27
+
28
+ kind: ObservationKind
29
+ name: str | None
30
+ elapsed: float
31
+ status: ExecutionStatus
32
+ correlation_id: str | None = None
33
+ error: BaseException | None = None
34
+
35
+
36
+ class PipelineObserver(Protocol):
37
+ """Synchronous, fast notification callable, also used by AsyncPipeline."""
38
+
39
+ def __call__(self, observation: PipelineObservation) -> None: ...
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ExecutionMetrics:
44
+ """Aggregate counts and durations, without retained payloads or errors."""
45
+
46
+ calls: int = 0
47
+ successful: int = 0
48
+ failed: int = 0
49
+ cancelled: int = 0
50
+ total_seconds: float = 0.0
51
+ max_seconds: float = 0.0
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class PipelineStatsSnapshot:
56
+ """Immutable aggregate view; disabled stages have no measurements."""
57
+
58
+ runs: ExecutionMetrics
59
+ stages: Mapping[str, ExecutionMetrics]
60
+ middleware: Mapping[str, ExecutionMetrics]
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class _MetricTotals:
65
+ calls: int = 0
66
+ successful: int = 0
67
+ failed: int = 0
68
+ cancelled: int = 0
69
+ total_seconds: float = 0.0
70
+ max_seconds: float = 0.0
71
+
72
+ def record(self, observation: PipelineObservation) -> None:
73
+ self.calls += 1
74
+ self.successful += observation.status == "success"
75
+ self.failed += observation.status == "error"
76
+ self.cancelled += observation.status == "cancelled"
77
+ self.total_seconds += observation.elapsed
78
+ self.max_seconds = max(self.max_seconds, observation.elapsed)
79
+
80
+ def freeze(self) -> ExecutionMetrics:
81
+ return ExecutionMetrics(
82
+ self.calls,
83
+ self.successful,
84
+ self.failed,
85
+ self.cancelled,
86
+ self.total_seconds,
87
+ self.max_seconds,
88
+ )
89
+
90
+
91
+ class PipelineStats:
92
+ """Thread-safe observer collecting per-run, stage and middleware aggregates."""
93
+
94
+ def __init__(self) -> None:
95
+ self._lock = threading.Lock()
96
+ self._metrics: dict[tuple[ObservationKind, str | None], _MetricTotals] = {}
97
+
98
+ def __call__(self, observation: PipelineObservation) -> None:
99
+ key = (observation.kind, observation.name)
100
+ with self._lock:
101
+ totals = self._metrics.get(key)
102
+ if totals is None:
103
+ totals = _MetricTotals()
104
+ self._metrics[key] = totals
105
+ totals.record(observation)
106
+
107
+ @property
108
+ def snapshot(self) -> PipelineStatsSnapshot:
109
+ """Copy aggregate counters; the returned mappings cannot be changed."""
110
+ with self._lock:
111
+ runs = self._metrics.get(("run", None))
112
+ return PipelineStatsSnapshot(
113
+ ExecutionMetrics() if runs is None else runs.freeze(),
114
+ MappingProxyType(
115
+ {
116
+ name: totals.freeze()
117
+ for (kind, name), totals in self._metrics.items()
118
+ if kind == "stage" and name is not None
119
+ }
120
+ ),
121
+ MappingProxyType(
122
+ {
123
+ name: totals.freeze()
124
+ for (kind, name), totals in self._metrics.items()
125
+ if kind == "middleware" and name is not None
126
+ }
127
+ ),
128
+ )
129
+
130
+ def reset(self) -> None:
131
+ """Reset counters; in-flight operations contribute when they finish."""
132
+ with self._lock:
133
+ self._metrics.clear()
@@ -0,0 +1,7 @@
1
+ """Independent typed pipelines, stage decorators and around middleware."""
2
+
3
+ from .async_ import AsyncPipeline
4
+ from .contracts import PipelineExecutionError
5
+ from .sync import Pipeline
6
+
7
+ __all__ = ["AsyncPipeline", "Pipeline", "PipelineExecutionError"]
@@ -0,0 +1,129 @@
1
+ """Asyncio pipeline execution using the same registry and observer contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import Awaitable, Callable, Coroutine
8
+ from time import perf_counter
9
+ from typing import Any, Generic, Literal, TypeVar
10
+
11
+ from .._callbacks import synchronous_result
12
+ from ..observability import PipelineObserver
13
+ from .base import _PipelineBase
14
+ from .registry import _Entry
15
+
16
+ T = TypeVar("T")
17
+ Stage = Callable[[T], Awaitable[T]]
18
+ Middleware = Callable[[T, Callable[[T], Awaitable[T]]], Awaitable[T]]
19
+
20
+
21
+ class AsyncPipeline(_PipelineBase[Stage[T], Middleware[T]], Generic[T]):
22
+ """Same-type async transformations on one owning event loop.
23
+
24
+ Concurrent runs share configuration but have independent continuations and
25
+ caller-owned payloads. Cancellation propagates directly into the active stage.
26
+ """
27
+
28
+ _asynchronous = True
29
+
30
+ def __init__(self, *, observer: PipelineObserver | None = None) -> None:
31
+ self._loop: asyncio.AbstractEventLoop | None = None
32
+ super().__init__(observer=observer)
33
+
34
+ async def _invoke(
35
+ self,
36
+ name: str,
37
+ phase: Literal["stage", "middleware"],
38
+ callback: Callable[..., Awaitable[T]],
39
+ context: T,
40
+ continuation: Callable[[T], Awaitable[T]] | None = None,
41
+ ) -> T:
42
+ started = perf_counter() if self._observer is not None else 0.0
43
+ try:
44
+ operation = (
45
+ callback(context) if continuation is None else callback(context, continuation)
46
+ )
47
+ if not inspect.isawaitable(operation):
48
+ raise TypeError("Async pipeline callbacks must return awaitables")
49
+ result = synchronous_result(await operation)
50
+ except BaseException as error:
51
+ self._raise(error, self._observe(phase, name, started, error), name, phase, context)
52
+ diagnostic = self._observe(phase, name, started)
53
+ if diagnostic is not None:
54
+ self._raise(diagnostic, None, name, "observer", context)
55
+ return result
56
+
57
+ def _compile(self) -> None:
58
+ stages = self._stages.plan
59
+
60
+ async def execute(context: T) -> T:
61
+ for entry in stages:
62
+ context = await self._invoke(entry.name, "stage", entry.callback, context)
63
+ return context
64
+
65
+ chain = execute
66
+ for entry in reversed(self._middleware.plan):
67
+ chain = self._layer(entry, chain)
68
+ self._execution = chain
69
+
70
+ def _layer(
71
+ self, entry: _Entry[Middleware[T]], following: Callable[[T], Awaitable[T]]
72
+ ) -> Callable[[T], Awaitable[T]]:
73
+ async def execute(context: T) -> T:
74
+ used = False
75
+ active = True
76
+ owner = asyncio.current_task()
77
+ continuation: Coroutine[Any, Any, T] | None = None
78
+
79
+ def next_(value: T) -> Coroutine[Any, Any, T]:
80
+ nonlocal continuation
81
+
82
+ if (
83
+ continuation is not None
84
+ and inspect.getcoroutinestate(continuation) is inspect.CORO_CREATED
85
+ ):
86
+ continuation.close()
87
+
88
+ async def continue_pipeline() -> T:
89
+ nonlocal used
90
+ if used or not active or asyncio.current_task() is not owner:
91
+ raise RuntimeError(
92
+ "Middleware continuation is single-use and owned by its call"
93
+ )
94
+ used = True
95
+ return await following(value)
96
+
97
+ continuation = continue_pipeline()
98
+ return continuation
99
+
100
+ try:
101
+ return await self._invoke(entry.name, "middleware", entry.callback, context, next_)
102
+ finally:
103
+ active = False
104
+ if (
105
+ continuation is not None
106
+ and inspect.getcoroutinestate(continuation) is inspect.CORO_CREATED
107
+ ):
108
+ continuation.close()
109
+
110
+ return execute
111
+
112
+ async def run(self, context: T) -> T:
113
+ """Execute a snapshot without starting worker tasks or swallowing cancellation."""
114
+ loop = asyncio.get_running_loop()
115
+ with self._lock:
116
+ if self._loop is None:
117
+ self._loop = loop
118
+ elif self._loop is not loop:
119
+ raise RuntimeError("AsyncPipeline belongs to a different event loop")
120
+ execute = self._execution
121
+ started = perf_counter() if self._observer is not None else 0.0
122
+ try:
123
+ result = await execute(context)
124
+ except BaseException as error:
125
+ self._raise(error, self._observe("run", None, started, error), None, "run", context)
126
+ diagnostic = self._observe("run", None, started)
127
+ if diagnostic is not None:
128
+ self._raise(diagnostic, None, None, "observer", context)
129
+ return result
@@ -0,0 +1,203 @@
1
+ """Shared registry, decorators, snapshots and observer error policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from collections.abc import Callable
8
+ from time import perf_counter
9
+ from typing import Generic, Literal, NoReturn, TypeVar
10
+
11
+ from .._callbacks import is_async_callable, synchronous_result
12
+ from ..context import current_correlation_id
13
+ from ..observability import ObservationKind, PipelineObservation, PipelineObserver
14
+ from .contracts import PipelineExecutionError
15
+ from .registry import _Registry
16
+
17
+ C = TypeVar("C")
18
+ M = TypeVar("M")
19
+ _MAX_MIDDLEWARE_COUNT = 64
20
+
21
+
22
+ class _PipelineBase(Generic[C, M]):
23
+ _asynchronous = False
24
+
25
+ def __init__(self, *, observer: PipelineObserver | None = None) -> None:
26
+ if observer is not None and (not callable(observer) or is_async_callable(observer)):
27
+ raise TypeError("Pipeline observer must be a synchronous callable")
28
+ self._observer = observer
29
+ self._lock = threading.RLock()
30
+ self._stages: _Registry[C] = _Registry()
31
+ self._middleware: _Registry[M] = _Registry()
32
+ self._compile()
33
+
34
+ def _compile(self) -> None:
35
+ raise NotImplementedError
36
+
37
+ def _validate(self, callback: object) -> None:
38
+ if not callable(callback):
39
+ raise TypeError("Pipeline callback must be callable")
40
+ if not self._asynchronous and is_async_callable(callback):
41
+ raise TypeError("Synchronous pipeline requires synchronous callbacks")
42
+
43
+ @property
44
+ def stage_names(self) -> tuple[str, ...]:
45
+ """Names in execution order, including disabled stages."""
46
+ with self._lock:
47
+ return tuple(entry.name for entry in self._stages.ordered)
48
+
49
+ @property
50
+ def middleware_names(self) -> tuple[str, ...]:
51
+ """Names from outermost to innermost, including disabled middleware."""
52
+ with self._lock:
53
+ return tuple(entry.name for entry in self._middleware.ordered)
54
+
55
+ def add(
56
+ self, name: str, callback: C, *, before: str | None = None, after: str | None = None
57
+ ) -> None:
58
+ """Add a stage atomically; references must already exist and be acyclic."""
59
+ self._validate(callback)
60
+ with self._lock:
61
+ self._stages.add(name, callback, before, after)
62
+ self._compile()
63
+
64
+ def stage(
65
+ self, name: str, *, before: str | None = None, after: str | None = None
66
+ ) -> Callable[[C], C]:
67
+ """Register a stage while returning the original callable unchanged."""
68
+
69
+ def register(callback: C) -> C:
70
+ self.add(name, callback, before=before, after=after)
71
+ return callback
72
+
73
+ return register
74
+
75
+ def remove(self, name: str) -> C:
76
+ """Remove a stage and references to it; return its original callback."""
77
+ with self._lock:
78
+ callback = self._stages.remove(name)
79
+ self._compile()
80
+ return callback
81
+
82
+ def replace(self, name: str, callback: C) -> None:
83
+ """Replace a callback, retaining its name, order and enabled state."""
84
+ self._validate(callback)
85
+ with self._lock:
86
+ self._stages.replace(name, callback)
87
+ self._compile()
88
+
89
+ def enable(self, name: str) -> None:
90
+ """Enable a stage for future runs."""
91
+ with self._lock:
92
+ self._stages.enable(name, True)
93
+ self._compile()
94
+
95
+ def disable(self, name: str) -> None:
96
+ """Disable a stage for future runs; ordering constraints remain intact."""
97
+ with self._lock:
98
+ self._stages.enable(name, False)
99
+ self._compile()
100
+
101
+ def add_middleware(
102
+ self, name: str, callback: M, *, before: str | None = None, after: str | None = None
103
+ ) -> None:
104
+ """Register around middleware in a separate, ordered registry."""
105
+ self._validate(callback)
106
+ with self._lock:
107
+ if len(self._middleware.ordered) >= _MAX_MIDDLEWARE_COUNT:
108
+ raise ValueError(
109
+ f"Pipeline supports at most {_MAX_MIDDLEWARE_COUNT} middleware entries"
110
+ )
111
+ self._middleware.add(name, callback, before, after)
112
+ self._compile()
113
+
114
+ def middleware(
115
+ self, name: str, *, before: str | None = None, after: str | None = None
116
+ ) -> Callable[[M], M]:
117
+ """Register middleware and return its original callable unchanged."""
118
+
119
+ def register(callback: M) -> M:
120
+ self.add_middleware(name, callback, before=before, after=after)
121
+ return callback
122
+
123
+ return register
124
+
125
+ def remove_middleware(self, name: str) -> M:
126
+ """Remove middleware and its ordering references."""
127
+ with self._lock:
128
+ callback = self._middleware.remove(name)
129
+ self._compile()
130
+ return callback
131
+
132
+ def replace_middleware(self, name: str, callback: M) -> None:
133
+ """Replace middleware without changing its position."""
134
+ self._validate(callback)
135
+ with self._lock:
136
+ self._middleware.replace(name, callback)
137
+ self._compile()
138
+
139
+ def enable_middleware(self, name: str) -> None:
140
+ """Enable middleware for future runs."""
141
+ with self._lock:
142
+ self._middleware.enable(name, True)
143
+ self._compile()
144
+
145
+ def disable_middleware(self, name: str) -> None:
146
+ """Disable middleware for future runs."""
147
+ with self._lock:
148
+ self._middleware.enable(name, False)
149
+ self._compile()
150
+
151
+ def _observe(
152
+ self,
153
+ kind: ObservationKind,
154
+ name: str | None,
155
+ started: float,
156
+ error: BaseException | None = None,
157
+ ) -> Exception | None:
158
+ if self._observer is None:
159
+ return None
160
+ status: Literal["success", "error", "cancelled"] = (
161
+ "cancelled"
162
+ if isinstance(error, asyncio.CancelledError)
163
+ else "error"
164
+ if error is not None
165
+ else "success"
166
+ )
167
+ try:
168
+ synchronous_result(
169
+ self._observer(
170
+ PipelineObservation(
171
+ kind,
172
+ name,
173
+ perf_counter() - started,
174
+ status,
175
+ current_correlation_id(),
176
+ error,
177
+ )
178
+ )
179
+ )
180
+ except Exception as observer_error:
181
+ return observer_error
182
+ return None
183
+
184
+ @staticmethod
185
+ def _raise(
186
+ error: BaseException,
187
+ diagnostic: Exception | None,
188
+ name: str | None,
189
+ phase: Literal["stage", "middleware", "run", "observer"],
190
+ context: object,
191
+ ) -> NoReturn:
192
+ if isinstance(error, PipelineExecutionError):
193
+ if diagnostic is not None:
194
+ error.observer_errors += (diagnostic,)
195
+ raise error
196
+ if isinstance(error, Exception):
197
+ failure = PipelineExecutionError(name, context, phase=phase)
198
+ if diagnostic is not None:
199
+ failure.observer_errors = (diagnostic,)
200
+ raise failure from error
201
+ if diagnostic is not None:
202
+ raise error from diagnostic
203
+ raise error
@@ -0,0 +1,24 @@
1
+ """Pipeline execution errors shared by sync and async implementations."""
2
+
3
+ from typing import Literal
4
+
5
+
6
+ class PipelineExecutionError(RuntimeError):
7
+ """Fail-fast error with component, input and the original exception as cause.
8
+
9
+ Observer failures during an existing failure are retained separately so
10
+ instrumentation cannot replace the original cause.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ name: str | None,
16
+ context: object,
17
+ *,
18
+ phase: Literal["stage", "middleware", "run", "observer"] = "stage",
19
+ ) -> None:
20
+ self.name = name
21
+ self.context = context
22
+ self.phase = phase
23
+ self.observer_errors: tuple[Exception, ...] = ()
24
+ super().__init__(f"Pipeline {phase} failed: {name or '<run>'}")