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/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,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>'}")
|