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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Copy-on-change ordered registries; callers serialize configuration changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
C = TypeVar("C")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _name(value: str) -> str:
|
|
13
|
+
if not isinstance(value, str) or not value.strip():
|
|
14
|
+
raise ValueError("Pipeline component name must be a non-empty string")
|
|
15
|
+
return value.strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class _Entry(Generic[C]):
|
|
20
|
+
name: str
|
|
21
|
+
callback: C
|
|
22
|
+
before: str | None = None
|
|
23
|
+
after: str | None = None
|
|
24
|
+
enabled: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Registry(Generic[C]):
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._entries: dict[str, _Entry[C]] = {}
|
|
30
|
+
self.ordered: tuple[_Entry[C], ...] = ()
|
|
31
|
+
self.plan: tuple[_Entry[C], ...] = ()
|
|
32
|
+
|
|
33
|
+
def _commit(self, entries: dict[str, _Entry[C]]) -> None:
|
|
34
|
+
positions = {name: index for index, name in enumerate(entries)}
|
|
35
|
+
edges: dict[str, set[str]] = {name: set() for name in entries}
|
|
36
|
+
degree = dict.fromkeys(entries, 0)
|
|
37
|
+
for entry in entries.values():
|
|
38
|
+
for source, target in ((entry.name, entry.before), (entry.after, entry.name)):
|
|
39
|
+
if source is None or target is None:
|
|
40
|
+
continue
|
|
41
|
+
if source not in entries or target not in entries:
|
|
42
|
+
raise KeyError(f"Unknown pipeline ordering reference: {source!r}, {target!r}")
|
|
43
|
+
if target not in edges[source]:
|
|
44
|
+
edges[source].add(target)
|
|
45
|
+
degree[target] += 1
|
|
46
|
+
ready = [(positions[name], name) for name, count in degree.items() if count == 0]
|
|
47
|
+
heapq.heapify(ready)
|
|
48
|
+
ordered = []
|
|
49
|
+
while ready:
|
|
50
|
+
_, name = heapq.heappop(ready)
|
|
51
|
+
ordered.append(entries[name])
|
|
52
|
+
for target in edges[name]:
|
|
53
|
+
degree[target] -= 1
|
|
54
|
+
if degree[target] == 0:
|
|
55
|
+
heapq.heappush(ready, (positions[target], target))
|
|
56
|
+
if len(ordered) != len(entries):
|
|
57
|
+
raise ValueError("Pipeline ordering contains a cycle")
|
|
58
|
+
self._entries = entries
|
|
59
|
+
self.ordered = tuple(ordered)
|
|
60
|
+
self.plan = tuple(entry for entry in ordered if entry.enabled)
|
|
61
|
+
|
|
62
|
+
def add(self, name: str, callback: C, before: str | None, after: str | None) -> None:
|
|
63
|
+
name = _name(name)
|
|
64
|
+
if name in self._entries:
|
|
65
|
+
raise ValueError(f"Pipeline component already exists: {name}")
|
|
66
|
+
entry = _Entry(
|
|
67
|
+
name,
|
|
68
|
+
callback,
|
|
69
|
+
_name(before) if before is not None else None,
|
|
70
|
+
_name(after) if after is not None else None,
|
|
71
|
+
)
|
|
72
|
+
self._commit({**self._entries, name: entry})
|
|
73
|
+
|
|
74
|
+
def remove(self, name: str) -> C:
|
|
75
|
+
name = _name(name)
|
|
76
|
+
removed = self._entries[name]
|
|
77
|
+
entries = {
|
|
78
|
+
key: replace(
|
|
79
|
+
value,
|
|
80
|
+
before=None if value.before == name else value.before,
|
|
81
|
+
after=None if value.after == name else value.after,
|
|
82
|
+
)
|
|
83
|
+
for key, value in self._entries.items()
|
|
84
|
+
if key != name
|
|
85
|
+
}
|
|
86
|
+
self._commit(entries)
|
|
87
|
+
return removed.callback
|
|
88
|
+
|
|
89
|
+
def replace(self, name: str, callback: C) -> None:
|
|
90
|
+
name = _name(name)
|
|
91
|
+
self._commit({**self._entries, name: replace(self._entries[name], callback=callback)})
|
|
92
|
+
|
|
93
|
+
def enable(self, name: str, enabled: bool) -> None:
|
|
94
|
+
name = _name(name)
|
|
95
|
+
self._commit({**self._entries, name: replace(self._entries[name], enabled=enabled)})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Thread-safe configuration and snapshot execution for synchronous pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from threading import get_ident
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from typing import Generic, Literal, TypeVar
|
|
9
|
+
|
|
10
|
+
from .._callbacks import synchronous_result
|
|
11
|
+
from .base import _PipelineBase
|
|
12
|
+
from .registry import _Entry
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
Stage = Callable[[T], T]
|
|
16
|
+
Middleware = Callable[[T, Callable[[T], T]], T]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Pipeline(_PipelineBase[Stage[T], Middleware[T]], Generic[T]):
|
|
20
|
+
"""Run same-type transformations with optional around middleware.
|
|
21
|
+
|
|
22
|
+
Each run uses a compiled immutable plan. Configuration changes apply to
|
|
23
|
+
future runs. Payload mutation is the caller's responsibility.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def _invoke(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
phase: Literal["stage", "middleware"],
|
|
30
|
+
callback: Callable[..., T],
|
|
31
|
+
context: T,
|
|
32
|
+
continuation: Callable[[T], T] | None = None,
|
|
33
|
+
) -> T:
|
|
34
|
+
started = perf_counter() if self._observer is not None else 0.0
|
|
35
|
+
try:
|
|
36
|
+
result = callback(context) if continuation is None else callback(context, continuation)
|
|
37
|
+
result = synchronous_result(result)
|
|
38
|
+
except BaseException as error:
|
|
39
|
+
self._raise(error, self._observe(phase, name, started, error), name, phase, context)
|
|
40
|
+
diagnostic = self._observe(phase, name, started)
|
|
41
|
+
if diagnostic is not None:
|
|
42
|
+
self._raise(diagnostic, None, name, "observer", context)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def _compile(self) -> None:
|
|
46
|
+
stages = self._stages.plan
|
|
47
|
+
|
|
48
|
+
def execute(context: T) -> T:
|
|
49
|
+
for entry in stages:
|
|
50
|
+
context = self._invoke(entry.name, "stage", entry.callback, context)
|
|
51
|
+
return context
|
|
52
|
+
|
|
53
|
+
chain = execute
|
|
54
|
+
for entry in reversed(self._middleware.plan):
|
|
55
|
+
chain = self._layer(entry, chain)
|
|
56
|
+
self._execution = chain
|
|
57
|
+
|
|
58
|
+
def _layer(self, entry: _Entry[Middleware[T]], following: Callable[[T], T]) -> Callable[[T], T]:
|
|
59
|
+
def execute(context: T) -> T:
|
|
60
|
+
used = False
|
|
61
|
+
active = True
|
|
62
|
+
owner = get_ident()
|
|
63
|
+
|
|
64
|
+
def next_(value: T) -> T:
|
|
65
|
+
nonlocal used
|
|
66
|
+
if used or not active or get_ident() != owner:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"Middleware continuation is single-use and scoped to its call"
|
|
69
|
+
)
|
|
70
|
+
used = True
|
|
71
|
+
return following(value)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return self._invoke(entry.name, "middleware", entry.callback, context, next_)
|
|
75
|
+
finally:
|
|
76
|
+
active = False
|
|
77
|
+
|
|
78
|
+
return execute
|
|
79
|
+
|
|
80
|
+
def run(self, context: T) -> T:
|
|
81
|
+
"""Execute one snapshot; failures preserve their cause and stop later stages."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
execute = self._execution
|
|
84
|
+
started = perf_counter() if self._observer is not None else 0.0
|
|
85
|
+
try:
|
|
86
|
+
result = execute(context)
|
|
87
|
+
except BaseException as error:
|
|
88
|
+
self._raise(error, self._observe("run", None, started, error), None, "run", context)
|
|
89
|
+
diagnostic = self._observe("run", None, started)
|
|
90
|
+
if diagnostic is not None:
|
|
91
|
+
self._raise(diagnostic, None, None, "observer", context)
|
|
92
|
+
return result
|
eventflow/py.typed
ADDED
|
File without changes
|
eventflow/scopes.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Explicit ownership of existing subscription and cancellation handles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Awaitable
|
|
9
|
+
from typing import Protocol, TypeVar
|
|
10
|
+
|
|
11
|
+
from ._callbacks import is_async_callable, synchronous_result
|
|
12
|
+
from ._lifecycle import finish_cleanup
|
|
13
|
+
from .compatibility import BaseExceptionGroup, ExceptionGroup
|
|
14
|
+
|
|
15
|
+
__all__ = ["CancelHandle", "AsyncCancelHandle", "SubscriptionScope", "AsyncSubscriptionScope"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CancelHandle(Protocol):
|
|
19
|
+
"""A synchronous, preferably idempotent cancellation handle."""
|
|
20
|
+
|
|
21
|
+
def cancel(self) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncCancelHandle(Protocol):
|
|
25
|
+
"""A handle whose cancel method returns an awaitable, preferably idempotently."""
|
|
26
|
+
|
|
27
|
+
def cancel(self) -> Awaitable[None]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
H = TypeVar("H", bound=CancelHandle)
|
|
31
|
+
A = TypeVar("A", bound=AsyncCancelHandle)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _raise_cleanup_errors(errors: list[BaseException]) -> None:
|
|
35
|
+
if not errors:
|
|
36
|
+
return
|
|
37
|
+
for index, error in enumerate(errors):
|
|
38
|
+
if not isinstance(error, Exception):
|
|
39
|
+
remaining = errors[:index] + errors[index + 1 :]
|
|
40
|
+
if remaining:
|
|
41
|
+
raise error from BaseExceptionGroup("Subscription scope cleanup failed", remaining)
|
|
42
|
+
raise error
|
|
43
|
+
ordinary = [error for error in errors if isinstance(error, Exception)]
|
|
44
|
+
raise ExceptionGroup("Subscription scope cleanup failed", ordinary)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SubscriptionScope:
|
|
48
|
+
"""Cancel owned handles in reverse registration order on exit.
|
|
49
|
+
|
|
50
|
+
All handles are attempted. Failed handles remain owned for a later retry.
|
|
51
|
+
A closed scope rejects additions, including after an unsuccessful close.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._handles: list[CancelHandle] = []
|
|
56
|
+
self._lock = threading.RLock()
|
|
57
|
+
self._close_lock = threading.RLock()
|
|
58
|
+
self._closed = False
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def closed(self) -> bool:
|
|
62
|
+
"""Whether the scope has stopped accepting handles."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
return self._closed
|
|
65
|
+
|
|
66
|
+
def add(self, handle: H) -> H:
|
|
67
|
+
"""Own a handle and return it unchanged; duplicate identities are ignored."""
|
|
68
|
+
cancel = getattr(handle, "cancel", None)
|
|
69
|
+
if not callable(cancel) or is_async_callable(cancel):
|
|
70
|
+
raise TypeError("SubscriptionScope requires a synchronous cancel handle")
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._closed:
|
|
73
|
+
raise RuntimeError("Cannot add a handle to a closed subscription scope")
|
|
74
|
+
if not any(item is handle for item in self._handles):
|
|
75
|
+
self._handles.append(handle)
|
|
76
|
+
return handle
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Attempt every cancellation, retaining failures and reporting them together."""
|
|
80
|
+
with self._close_lock:
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._closed = True
|
|
83
|
+
pending, self._handles = list(reversed(self._handles)), []
|
|
84
|
+
failed: list[CancelHandle] = []
|
|
85
|
+
errors: list[BaseException] = []
|
|
86
|
+
for handle in pending:
|
|
87
|
+
try:
|
|
88
|
+
synchronous_result(handle.cancel())
|
|
89
|
+
except BaseException as error:
|
|
90
|
+
failed.append(handle)
|
|
91
|
+
errors.append(error)
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._handles = list(reversed(failed))
|
|
94
|
+
_raise_cleanup_errors(errors)
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> SubscriptionScope:
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *_args: object) -> None:
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncSubscriptionScope:
|
|
104
|
+
"""Own async handles on one loop and finish cleanup despite caller cancellation.
|
|
105
|
+
|
|
106
|
+
Cleanup runs sequentially in reverse registration order. Concurrent aclose
|
|
107
|
+
callers await the same cleanup task. Failed handles can be retried.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._handles: list[AsyncCancelHandle] = []
|
|
112
|
+
self._closed = False
|
|
113
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
114
|
+
self._cleanup: asyncio.Task[list[BaseException]] | None = None
|
|
115
|
+
|
|
116
|
+
def _bind_loop(self) -> None:
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
if self._loop is None:
|
|
119
|
+
self._loop = loop
|
|
120
|
+
elif self._loop is not loop:
|
|
121
|
+
raise RuntimeError("AsyncSubscriptionScope belongs to a different event loop")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def closed(self) -> bool:
|
|
125
|
+
"""Whether the scope has stopped accepting handles."""
|
|
126
|
+
return self._closed
|
|
127
|
+
|
|
128
|
+
def add(self, handle: A) -> A:
|
|
129
|
+
"""Own an awaitable cancel handle and return it unchanged.
|
|
130
|
+
|
|
131
|
+
The cancel method is not called here because doing so would cancel the
|
|
132
|
+
handle. Its return value is validated when the scope closes.
|
|
133
|
+
"""
|
|
134
|
+
if not callable(getattr(handle, "cancel", None)):
|
|
135
|
+
raise TypeError("AsyncSubscriptionScope requires an awaitable cancel handle")
|
|
136
|
+
if self._loop is not None:
|
|
137
|
+
self._bind_loop()
|
|
138
|
+
if self._closed:
|
|
139
|
+
raise RuntimeError("Cannot add a handle to a closed subscription scope")
|
|
140
|
+
if not any(item is handle for item in self._handles):
|
|
141
|
+
self._handles.append(handle)
|
|
142
|
+
return handle
|
|
143
|
+
|
|
144
|
+
async def _cancel_handles(self) -> list[BaseException]:
|
|
145
|
+
pending, self._handles = list(reversed(self._handles)), []
|
|
146
|
+
failed: list[AsyncCancelHandle] = []
|
|
147
|
+
errors: list[BaseException] = []
|
|
148
|
+
for handle in pending:
|
|
149
|
+
try:
|
|
150
|
+
operation = handle.cancel()
|
|
151
|
+
if not inspect.isawaitable(operation):
|
|
152
|
+
raise TypeError("Async cancel handle cancel() must return an awaitable")
|
|
153
|
+
synchronous_result(await operation)
|
|
154
|
+
except BaseException as error:
|
|
155
|
+
failed.append(handle)
|
|
156
|
+
errors.append(error)
|
|
157
|
+
self._handles = list(reversed(failed))
|
|
158
|
+
return errors
|
|
159
|
+
|
|
160
|
+
async def aclose(self) -> None:
|
|
161
|
+
"""Finish all cancellations; caller cancellation propagates after cleanup."""
|
|
162
|
+
self._bind_loop()
|
|
163
|
+
self._closed = True
|
|
164
|
+
if self._cleanup is None or (self._cleanup.done() and self._handles):
|
|
165
|
+
self._cleanup = asyncio.create_task(
|
|
166
|
+
self._cancel_handles(), name="EventFlowScopeCleanup"
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
errors = await finish_cleanup(self._cleanup)
|
|
170
|
+
except asyncio.CancelledError as cancellation:
|
|
171
|
+
if self._cleanup.done() and not self._cleanup.cancelled():
|
|
172
|
+
errors = self._cleanup.result()
|
|
173
|
+
if errors:
|
|
174
|
+
raise cancellation from BaseExceptionGroup(
|
|
175
|
+
"Subscription scope cleanup failed", errors
|
|
176
|
+
)
|
|
177
|
+
raise
|
|
178
|
+
_raise_cleanup_errors(errors)
|
|
179
|
+
|
|
180
|
+
async def __aenter__(self) -> AsyncSubscriptionScope:
|
|
181
|
+
self._bind_loop()
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
185
|
+
await self.aclose()
|
eventflow/stream.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Lazy single-consumer async streams with opt-in bounded prefetch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
|
|
8
|
+
from typing import Any, Generic, NoReturn, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from ._lifecycle import finish_cleanup
|
|
11
|
+
from .base import validate_positive_capacity
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
U = TypeVar("U")
|
|
15
|
+
__all__ = ["AsyncStream"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _raise_with_cleanup(primary: BaseException, cleanup: BaseException) -> NoReturn:
|
|
19
|
+
if cleanup is primary:
|
|
20
|
+
raise primary
|
|
21
|
+
if isinstance(cleanup, Exception):
|
|
22
|
+
raise primary from cleanup
|
|
23
|
+
raise cleanup from primary
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncStream(AsyncIterator[T], Generic[T]):
|
|
27
|
+
"""Own one async source and consume it once on one event loop.
|
|
28
|
+
|
|
29
|
+
map/filter/batch are lazy and create no worker tasks. Use ``async with``
|
|
30
|
+
around the terminal stream for deterministic cleanup on an early break.
|
|
31
|
+
aclose cancels an outstanding read, then closes sources and producers.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, source: AsyncIterable[T], *, _upstream: AsyncStream[Any] | None = None
|
|
36
|
+
) -> None:
|
|
37
|
+
if not isinstance(source, AsyncIterable):
|
|
38
|
+
raise TypeError("AsyncStream source must be async iterable")
|
|
39
|
+
self._source = source
|
|
40
|
+
self._upstream = _upstream
|
|
41
|
+
self._iterator: AsyncIterator[T] | None = None
|
|
42
|
+
self._claimed = False
|
|
43
|
+
self._closed = False
|
|
44
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
45
|
+
self._consumer: asyncio.Task[Any] | None = None
|
|
46
|
+
self._reader: asyncio.Task[Any] | None = None
|
|
47
|
+
self._read_finished: asyncio.Event | None = None
|
|
48
|
+
self._cleanup: asyncio.Task[None] | None = None
|
|
49
|
+
|
|
50
|
+
def _bind_loop(self) -> None:
|
|
51
|
+
loop = asyncio.get_running_loop()
|
|
52
|
+
if self._loop is None:
|
|
53
|
+
self._loop = loop
|
|
54
|
+
elif self._loop is not loop:
|
|
55
|
+
raise RuntimeError("AsyncStream belongs to a different event loop")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def closed(self) -> bool:
|
|
59
|
+
"""Whether the stream no longer accepts reads."""
|
|
60
|
+
return self._closed
|
|
61
|
+
|
|
62
|
+
def __aiter__(self) -> AsyncStream[T]:
|
|
63
|
+
self._bind_loop()
|
|
64
|
+
if self._claimed:
|
|
65
|
+
raise RuntimeError("AsyncStream supports one consumption only")
|
|
66
|
+
if self._closed:
|
|
67
|
+
raise RuntimeError("AsyncStream is closed")
|
|
68
|
+
self._claimed = True
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
async def __anext__(self) -> T:
|
|
72
|
+
self._bind_loop()
|
|
73
|
+
if self._closed:
|
|
74
|
+
raise StopAsyncIteration
|
|
75
|
+
if self._reader is not None:
|
|
76
|
+
raise RuntimeError("Concurrent reads from one AsyncStream are not supported")
|
|
77
|
+
task = asyncio.current_task()
|
|
78
|
+
if self._consumer is None:
|
|
79
|
+
self._consumer = task
|
|
80
|
+
elif self._consumer is not task:
|
|
81
|
+
raise RuntimeError("AsyncStream must be consumed by one task")
|
|
82
|
+
self._claimed = True
|
|
83
|
+
self._reader = task
|
|
84
|
+
try:
|
|
85
|
+
if self._iterator is None:
|
|
86
|
+
self._iterator = self._source.__aiter__()
|
|
87
|
+
return await self._iterator.__anext__()
|
|
88
|
+
except BaseException as error:
|
|
89
|
+
self._reader = None
|
|
90
|
+
if not self._closed:
|
|
91
|
+
try:
|
|
92
|
+
await self.aclose()
|
|
93
|
+
except BaseException as cleanup_error:
|
|
94
|
+
if isinstance(error, StopAsyncIteration):
|
|
95
|
+
raise cleanup_error
|
|
96
|
+
_raise_with_cleanup(error, cleanup_error)
|
|
97
|
+
raise
|
|
98
|
+
finally:
|
|
99
|
+
self._reader = None
|
|
100
|
+
if self._read_finished is not None:
|
|
101
|
+
self._read_finished.set()
|
|
102
|
+
self._read_finished = None
|
|
103
|
+
|
|
104
|
+
def map(self, callback: Callable[[T], U | Awaitable[U]]) -> AsyncStream[U]:
|
|
105
|
+
"""Transform each item with a sync or awaitable callback, without threads."""
|
|
106
|
+
if not callable(callback):
|
|
107
|
+
raise TypeError("Stream map callback must be callable")
|
|
108
|
+
|
|
109
|
+
async def transformed() -> AsyncIterator[U]:
|
|
110
|
+
async for item in self:
|
|
111
|
+
result = callback(item)
|
|
112
|
+
yield await result if inspect.isawaitable(result) else cast(U, result)
|
|
113
|
+
|
|
114
|
+
return AsyncStream(transformed(), _upstream=self)
|
|
115
|
+
|
|
116
|
+
def filter(self, predicate: Callable[[T], bool | Awaitable[bool]]) -> AsyncStream[T]:
|
|
117
|
+
"""Keep matching items using a synchronous or awaitable predicate."""
|
|
118
|
+
if not callable(predicate):
|
|
119
|
+
raise TypeError("Stream filter predicate must be callable")
|
|
120
|
+
|
|
121
|
+
async def filtered() -> AsyncIterator[T]:
|
|
122
|
+
async for item in self:
|
|
123
|
+
accepted = predicate(item)
|
|
124
|
+
if inspect.isawaitable(accepted):
|
|
125
|
+
accepted = await accepted
|
|
126
|
+
if type(accepted) is not bool:
|
|
127
|
+
raise TypeError("Stream filter predicates must return bool")
|
|
128
|
+
if accepted:
|
|
129
|
+
yield item
|
|
130
|
+
|
|
131
|
+
return AsyncStream(filtered(), _upstream=self)
|
|
132
|
+
|
|
133
|
+
def batch(self, size: int, *, partial: bool = True) -> AsyncStream[tuple[T, ...]]:
|
|
134
|
+
"""Yield fixed-size tuples and optionally the final partial tuple."""
|
|
135
|
+
size = validate_positive_capacity(size, name="Stream batch size")
|
|
136
|
+
if not isinstance(partial, bool):
|
|
137
|
+
raise TypeError("Stream partial flag must be a bool")
|
|
138
|
+
|
|
139
|
+
async def batched() -> AsyncIterator[tuple[T, ...]]:
|
|
140
|
+
items = []
|
|
141
|
+
async for item in self:
|
|
142
|
+
items.append(item)
|
|
143
|
+
if len(items) == size:
|
|
144
|
+
yield tuple(items)
|
|
145
|
+
items.clear()
|
|
146
|
+
if items and partial:
|
|
147
|
+
yield tuple(items)
|
|
148
|
+
|
|
149
|
+
return AsyncStream(batched(), _upstream=self)
|
|
150
|
+
|
|
151
|
+
def buffer(self, capacity: int) -> AsyncStream[T]:
|
|
152
|
+
"""Prefetch at most capacity queued items plus one in-flight source item.
|
|
153
|
+
|
|
154
|
+
A full queue blocks its single producer. Values are never dropped.
|
|
155
|
+
Source failures propagate after already queued items have been read.
|
|
156
|
+
"""
|
|
157
|
+
capacity = validate_positive_capacity(capacity, name="Stream buffer capacity")
|
|
158
|
+
|
|
159
|
+
async def buffered() -> AsyncIterator[T]:
|
|
160
|
+
queue: asyncio.Queue[object] = asyncio.Queue(maxsize=capacity)
|
|
161
|
+
end = object()
|
|
162
|
+
error: BaseException | None = None
|
|
163
|
+
closing = False
|
|
164
|
+
|
|
165
|
+
async def produce() -> None:
|
|
166
|
+
nonlocal error
|
|
167
|
+
try:
|
|
168
|
+
async for item in self:
|
|
169
|
+
await queue.put(item)
|
|
170
|
+
except BaseException as exc:
|
|
171
|
+
if closing and isinstance(exc, asyncio.CancelledError):
|
|
172
|
+
raise
|
|
173
|
+
error = exc
|
|
174
|
+
await queue.put(end)
|
|
175
|
+
|
|
176
|
+
producer = asyncio.create_task(produce(), name="EventFlowStreamProducer")
|
|
177
|
+
try:
|
|
178
|
+
while True:
|
|
179
|
+
item = await queue.get()
|
|
180
|
+
if item is end:
|
|
181
|
+
if error is not None:
|
|
182
|
+
raise error
|
|
183
|
+
break
|
|
184
|
+
yield cast(T, item)
|
|
185
|
+
finally:
|
|
186
|
+
closing = True
|
|
187
|
+
producer.cancel()
|
|
188
|
+
await finish_cleanup(asyncio.gather(producer, return_exceptions=True))
|
|
189
|
+
|
|
190
|
+
return AsyncStream(buffered(), _upstream=self)
|
|
191
|
+
|
|
192
|
+
async def _shutdown(
|
|
193
|
+
self, reader: asyncio.Task[Any] | None, read_finished: asyncio.Event | None
|
|
194
|
+
) -> None:
|
|
195
|
+
if reader is not None and not reader.done():
|
|
196
|
+
reader.cancel()
|
|
197
|
+
if read_finished is not None:
|
|
198
|
+
await read_finished.wait()
|
|
199
|
+
source_error: BaseException | None = None
|
|
200
|
+
try:
|
|
201
|
+
source = self._iterator if self._iterator is not None else self._source
|
|
202
|
+
close = getattr(source, "aclose", None)
|
|
203
|
+
if close is not None:
|
|
204
|
+
await close()
|
|
205
|
+
except BaseException as error:
|
|
206
|
+
source_error = error
|
|
207
|
+
if self._upstream is not None:
|
|
208
|
+
try:
|
|
209
|
+
await self._upstream.aclose()
|
|
210
|
+
except BaseException as cleanup_error:
|
|
211
|
+
if source_error is not None:
|
|
212
|
+
_raise_with_cleanup(source_error, cleanup_error)
|
|
213
|
+
raise
|
|
214
|
+
if source_error is not None:
|
|
215
|
+
raise source_error
|
|
216
|
+
|
|
217
|
+
async def aclose(self) -> None:
|
|
218
|
+
"""Stop reads and await producer/source cleanup, even during repeated cancellation."""
|
|
219
|
+
self._bind_loop()
|
|
220
|
+
if self._reader is asyncio.current_task():
|
|
221
|
+
raise RuntimeError("Cannot close an AsyncStream from its active read task")
|
|
222
|
+
if self._cleanup is None:
|
|
223
|
+
self._closed = True
|
|
224
|
+
reader = self._reader
|
|
225
|
+
if reader is not None:
|
|
226
|
+
# Only external concurrent close needs a read-completion event.
|
|
227
|
+
self._read_finished = asyncio.Event()
|
|
228
|
+
self._cleanup = asyncio.create_task(
|
|
229
|
+
self._shutdown(reader, self._read_finished),
|
|
230
|
+
name="EventFlowStreamCleanup",
|
|
231
|
+
)
|
|
232
|
+
await finish_cleanup(self._cleanup)
|
|
233
|
+
|
|
234
|
+
async def __aenter__(self) -> AsyncStream[T]:
|
|
235
|
+
self._bind_loop()
|
|
236
|
+
if self._closed:
|
|
237
|
+
raise RuntimeError("AsyncStream is closed")
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
241
|
+
await self.aclose()
|