datacurrent 1.0.0__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.
- datacurrent/__init__.py +157 -0
- datacurrent/_version.py +6 -0
- datacurrent/buffering/__init__.py +8 -0
- datacurrent/buffering/backpressure.py +47 -0
- datacurrent/buffering/queue.py +304 -0
- datacurrent/concurrency/__init__.py +8 -0
- datacurrent/concurrency/limits.py +106 -0
- datacurrent/concurrency/tasks.py +86 -0
- datacurrent/config.py +86 -0
- datacurrent/core/__init__.py +17 -0
- datacurrent/core/_util.py +42 -0
- datacurrent/core/lifecycle.py +81 -0
- datacurrent/core/operators.py +844 -0
- datacurrent/core/pipeline.py +64 -0
- datacurrent/core/sink.py +70 -0
- datacurrent/core/stream.py +566 -0
- datacurrent/events/__init__.py +9 -0
- datacurrent/events/bus.py +380 -0
- datacurrent/events/routing.py +59 -0
- datacurrent/events/subscriptions.py +75 -0
- datacurrent/exceptions.py +191 -0
- datacurrent/integrations/__init__.py +18 -0
- datacurrent/integrations/fastapi.py +76 -0
- datacurrent/integrations/starlette.py +75 -0
- datacurrent/observability/__init__.py +17 -0
- datacurrent/observability/logging.py +128 -0
- datacurrent/observability/metrics.py +127 -0
- datacurrent/py.typed +1 -0
- datacurrent/reliability/__init__.py +14 -0
- datacurrent/reliability/retry.py +134 -0
- datacurrent/reliability/timeout.py +39 -0
- datacurrent/transports/__init__.py +31 -0
- datacurrent/transports/_deps.py +23 -0
- datacurrent/transports/base.py +80 -0
- datacurrent/transports/http.py +211 -0
- datacurrent/transports/memory.py +171 -0
- datacurrent/transports/sse.py +374 -0
- datacurrent/transports/websocket.py +277 -0
- datacurrent-1.0.0.dist-info/METADATA +250 -0
- datacurrent-1.0.0.dist-info/RECORD +43 -0
- datacurrent-1.0.0.dist-info/WHEEL +5 -0
- datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
- datacurrent-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Pipeline construction helpers.
|
|
2
|
+
|
|
3
|
+
``Stream`` methods are the primary API; these helpers cover the two cases where
|
|
4
|
+
a free function reads better than a method: building the first stream and
|
|
5
|
+
combining several sources.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterable, Iterable
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from ..config import StreamConfig
|
|
14
|
+
from .stream import Stream
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
__all__ = ["concat_sources", "merge_sources", "pipeline"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pipeline(
|
|
22
|
+
source: Iterable[T] | AsyncIterable[T] | Any = None,
|
|
23
|
+
*,
|
|
24
|
+
config: StreamConfig | None = None,
|
|
25
|
+
) -> Stream[T]:
|
|
26
|
+
"""Create the first stream of a pipeline.
|
|
27
|
+
|
|
28
|
+
``pipeline()`` with no argument returns an empty stream, which is handy when
|
|
29
|
+
the real source is decided later.
|
|
30
|
+
"""
|
|
31
|
+
if source is None:
|
|
32
|
+
return Stream.empty(config=config)
|
|
33
|
+
if isinstance(source, Stream):
|
|
34
|
+
return source
|
|
35
|
+
return Stream(source, config=config)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def merge_sources(
|
|
39
|
+
*sources: Iterable[Any] | AsyncIterable[Any] | Stream[Any],
|
|
40
|
+
buffer_size: int | None = None,
|
|
41
|
+
config: StreamConfig | None = None,
|
|
42
|
+
) -> Stream[Any]:
|
|
43
|
+
"""Merge several sources into one concurrent stream."""
|
|
44
|
+
if not sources:
|
|
45
|
+
return Stream.empty(config=config)
|
|
46
|
+
first, *rest = sources
|
|
47
|
+
head: Stream[Any] = pipeline(first, config=config)
|
|
48
|
+
if not rest:
|
|
49
|
+
return head
|
|
50
|
+
return head.merge(*rest, buffer_size=buffer_size)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def concat_sources(
|
|
54
|
+
*sources: Iterable[Any] | AsyncIterable[Any] | Stream[Any],
|
|
55
|
+
config: StreamConfig | None = None,
|
|
56
|
+
) -> Stream[Any]:
|
|
57
|
+
"""Concatenate several sources into one ordered stream."""
|
|
58
|
+
if not sources:
|
|
59
|
+
return Stream.empty(config=config)
|
|
60
|
+
first, *rest = sources
|
|
61
|
+
head: Stream[Any] = pipeline(first, config=config)
|
|
62
|
+
if not rest:
|
|
63
|
+
return head
|
|
64
|
+
return head.concat(*rest)
|
datacurrent/core/sink.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Helpers to drive a stream into application code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from ..config import DEFAULT_CONCURRENCY
|
|
9
|
+
from ..exceptions import ConfigurationError
|
|
10
|
+
from .stream import Stream
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
__all__ = ["consume", "drain_into"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def consume(
|
|
18
|
+
stream: Stream[T],
|
|
19
|
+
handler: Callable[[T], Any],
|
|
20
|
+
*,
|
|
21
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
22
|
+
ordered: bool = True,
|
|
23
|
+
) -> int:
|
|
24
|
+
"""Await ``handler(item)`` for every item and return the item count.
|
|
25
|
+
|
|
26
|
+
``handler`` may be synchronous or async. With ``concurrency > 1`` the
|
|
27
|
+
handler runs in a bounded number of tasks; exceptions cancel the remaining
|
|
28
|
+
work and propagate.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
count = await consume(stream, send_email, concurrency=8)
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(stream, Stream):
|
|
35
|
+
raise ConfigurationError("consume() expects a Stream")
|
|
36
|
+
if not callable(handler):
|
|
37
|
+
raise TypeError(f"handler must be callable, got {type(handler).__name__}")
|
|
38
|
+
if isinstance(concurrency, bool) or not isinstance(concurrency, int):
|
|
39
|
+
raise ConfigurationError("concurrency must be an integer")
|
|
40
|
+
if concurrency < 1:
|
|
41
|
+
raise ConfigurationError(f"concurrency must be >= 1, got {concurrency}")
|
|
42
|
+
|
|
43
|
+
if concurrency == 1:
|
|
44
|
+
total = 0
|
|
45
|
+
async for item in stream:
|
|
46
|
+
await _maybe(handler(item))
|
|
47
|
+
total += 1
|
|
48
|
+
return total
|
|
49
|
+
|
|
50
|
+
mapped = stream.map(handler, concurrency=concurrency, ordered=ordered)
|
|
51
|
+
return await mapped.count()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def drain_into(
|
|
55
|
+
stream: Stream[T],
|
|
56
|
+
sink: Any,
|
|
57
|
+
*,
|
|
58
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
59
|
+
) -> int:
|
|
60
|
+
"""Push every item into ``sink.put()`` (awaiting it when necessary)."""
|
|
61
|
+
put = getattr(sink, "put", None)
|
|
62
|
+
if put is None:
|
|
63
|
+
raise ConfigurationError("drain_into() expects a sink exposing put(item)")
|
|
64
|
+
return await consume(stream, put, concurrency=concurrency)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _maybe(value: Any) -> Any:
|
|
68
|
+
from ._util import maybe_await
|
|
69
|
+
|
|
70
|
+
return await maybe_await(value)
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""The core :class:`Stream` type: one async pipeline, one clear contract.
|
|
2
|
+
|
|
3
|
+
A ``Stream`` is a lazy, single-consumer async iterable. Nothing runs until you
|
|
4
|
+
iterate it, and every iteration opens the pipeline exactly once. For multiple
|
|
5
|
+
independent consumers use :class:`datacurrent.Channel` or
|
|
6
|
+
:class:`datacurrent.EventBus`, which implement explicit fan-out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from typing import Any, Generic, TypeVar
|
|
15
|
+
|
|
16
|
+
from ..buffering.queue import BoundedQueue
|
|
17
|
+
from ..config import OverflowPolicy, StreamConfig
|
|
18
|
+
from ..exceptions import (
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
EmptyStreamError,
|
|
21
|
+
NonReplayableStreamError,
|
|
22
|
+
StreamConsumedError,
|
|
23
|
+
)
|
|
24
|
+
from ..observability.metrics import StreamStats, snapshot_stats
|
|
25
|
+
from ..reliability.retry import RetryPolicy
|
|
26
|
+
from . import operators as ops
|
|
27
|
+
from ._util import as_aiterator, maybe_await
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
U = TypeVar("U")
|
|
31
|
+
_UNSET = object()
|
|
32
|
+
|
|
33
|
+
__all__ = ["Stream", "StreamIterator"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StreamIterator(Generic[T]):
|
|
37
|
+
"""Wrap an async generator with statistics and deterministic ``aclose``."""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_closed", "_exhausted", "_generator", "_stats")
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
generator: AsyncIterator[T],
|
|
44
|
+
*,
|
|
45
|
+
stats: StreamStats | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._generator = generator
|
|
48
|
+
self._stats = stats
|
|
49
|
+
self._closed = False
|
|
50
|
+
self._exhausted = False
|
|
51
|
+
|
|
52
|
+
def __aiter__(self) -> StreamIterator[T]:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __anext__(self) -> T:
|
|
56
|
+
if self._closed:
|
|
57
|
+
raise StopAsyncIteration
|
|
58
|
+
try:
|
|
59
|
+
item = await self._generator.__anext__()
|
|
60
|
+
except StopAsyncIteration:
|
|
61
|
+
self._closed = True
|
|
62
|
+
self._exhausted = True
|
|
63
|
+
raise
|
|
64
|
+
except asyncio.CancelledError:
|
|
65
|
+
raise
|
|
66
|
+
except Exception:
|
|
67
|
+
if self._stats is not None:
|
|
68
|
+
self._stats.errors += 1
|
|
69
|
+
self._closed = True
|
|
70
|
+
raise
|
|
71
|
+
if self._stats is not None:
|
|
72
|
+
self._stats.items_out += 1
|
|
73
|
+
return item
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
"""Close the underlying generator; idempotent and cancellation-safe."""
|
|
77
|
+
if self._exhausted:
|
|
78
|
+
return
|
|
79
|
+
self._exhausted = True
|
|
80
|
+
self._closed = True
|
|
81
|
+
closer = getattr(self._generator, "aclose", None)
|
|
82
|
+
if closer is None:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
await closer()
|
|
86
|
+
except (RuntimeError, StopAsyncIteration): # pragma: no cover - defensive
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self) -> StreamIterator[T]:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
93
|
+
await self.aclose()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def closed(self) -> bool:
|
|
97
|
+
return self._closed
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def exhausted(self) -> bool:
|
|
101
|
+
return self._exhausted
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
104
|
+
return f"StreamIterator(closed={self._closed}, exhausted={self._exhausted})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Stream(Generic[T]):
|
|
108
|
+
"""A lazy asynchronous sequence with composable operators.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
source: An iterable, an async iterable, a zero-argument callable that
|
|
112
|
+
returns one of those, or another :class:`Stream`.
|
|
113
|
+
config: Optional :class:`~datacurrent.StreamConfig`.
|
|
114
|
+
replayable: Override replay detection. Lists and tuples are replayable
|
|
115
|
+
automatically; generators and async generators are not.
|
|
116
|
+
|
|
117
|
+
Example::
|
|
118
|
+
|
|
119
|
+
from datacurrent import Stream
|
|
120
|
+
|
|
121
|
+
stream = Stream.from_iterable([1, 2, 3, 4, 5])
|
|
122
|
+
doubled = stream.filter(lambda n: n % 2).map(lambda n: n * 2)
|
|
123
|
+
async for value in doubled:
|
|
124
|
+
print(value)
|
|
125
|
+
|
|
126
|
+
A ``Stream`` is single-consumer: iterating it twice raises
|
|
127
|
+
:class:`~datacurrent.StreamConsumedError`. Use ``Channel`` or ``EventBus``
|
|
128
|
+
when several consumers must each receive every item.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
__slots__ = ("_config", "_consumed", "_current", "_factory", "_replayable", "_stats")
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
source: Any,
|
|
136
|
+
*,
|
|
137
|
+
config: StreamConfig | None = None,
|
|
138
|
+
replayable: bool | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
if config is not None and not isinstance(config, StreamConfig):
|
|
141
|
+
raise ConfigurationError(
|
|
142
|
+
f"config must be a StreamConfig instance or None, got {type(config).__name__}"
|
|
143
|
+
)
|
|
144
|
+
self._config: StreamConfig = config or StreamConfig()
|
|
145
|
+
self._consumed = False
|
|
146
|
+
self._current: StreamIterator[T] | None = None
|
|
147
|
+
self._stats = StreamStats(name=self._config.name)
|
|
148
|
+
|
|
149
|
+
if isinstance(source, Stream):
|
|
150
|
+
upstream: Stream[Any] = source
|
|
151
|
+
|
|
152
|
+
async def _drain_upstream() -> AsyncIterator[Any]:
|
|
153
|
+
async for item in upstream:
|
|
154
|
+
yield item
|
|
155
|
+
|
|
156
|
+
self._factory: Callable[[], AsyncIterator[T]] = _drain_upstream
|
|
157
|
+
self._replayable = False
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if _is_async_iterable(source) or hasattr(source, "__iter__"):
|
|
161
|
+
replayable_flag = (
|
|
162
|
+
bool(replayable)
|
|
163
|
+
if replayable is not None
|
|
164
|
+
else hasattr(source, "__len__") and not _is_async_iterable(source)
|
|
165
|
+
)
|
|
166
|
+
if replayable_flag:
|
|
167
|
+
self._factory = lambda: as_aiterator(source)
|
|
168
|
+
else:
|
|
169
|
+
cached: list[AsyncIterator[Any]] = []
|
|
170
|
+
|
|
171
|
+
def _one_shot() -> AsyncIterator[Any]:
|
|
172
|
+
if not cached:
|
|
173
|
+
cached.append(as_aiterator(source))
|
|
174
|
+
return cached[0]
|
|
175
|
+
|
|
176
|
+
self._factory = _one_shot
|
|
177
|
+
self._replayable = replayable_flag
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if callable(source):
|
|
181
|
+
factory: Callable[[], Any] = source
|
|
182
|
+
self._factory = lambda: as_aiterator(factory())
|
|
183
|
+
self._replayable = True if replayable is None else bool(replayable)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
raise TypeError(
|
|
187
|
+
"Stream() expects an iterable, an async iterable, a zero-argument "
|
|
188
|
+
f"callable or another Stream, got {type(source).__name__}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ #
|
|
192
|
+
# Construction
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_iterable(cls, items: Iterable[T], *, config: StreamConfig | None = None) -> Stream[T]:
|
|
196
|
+
"""Stream from a synchronous iterable.
|
|
197
|
+
|
|
198
|
+
A list or tuple is replayable (and therefore retryable); a plain
|
|
199
|
+
generator is single-use.
|
|
200
|
+
"""
|
|
201
|
+
if not hasattr(items, "__iter__"):
|
|
202
|
+
raise TypeError(f"from_iterable() expects an iterable, got {type(items).__name__}")
|
|
203
|
+
return cls(items, config=config)
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_async_iterable(
|
|
207
|
+
cls, items: AsyncIterable[T], *, config: StreamConfig | None = None
|
|
208
|
+
) -> Stream[T]:
|
|
209
|
+
"""Stream from an async iterable or async generator (single-use)."""
|
|
210
|
+
if not _is_async_iterable(items):
|
|
211
|
+
raise TypeError(
|
|
212
|
+
f"from_async_iterable() expects an async iterable, got {type(items).__name__}"
|
|
213
|
+
)
|
|
214
|
+
return cls(items, config=config)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_factory(
|
|
218
|
+
cls,
|
|
219
|
+
factory: Callable[[], Any],
|
|
220
|
+
*,
|
|
221
|
+
config: StreamConfig | None = None,
|
|
222
|
+
) -> Stream[T]:
|
|
223
|
+
"""Stream from a zero-argument callable, re-invoked on each retry."""
|
|
224
|
+
if not callable(factory):
|
|
225
|
+
raise TypeError("from_factory() expects a zero-argument callable")
|
|
226
|
+
return cls(factory, config=config, replayable=True)
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_value(cls, value: T, *, config: StreamConfig | None = None) -> Stream[T]:
|
|
230
|
+
"""Stream containing exactly one item."""
|
|
231
|
+
return cls([value], config=config)
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def empty(cls, *, config: StreamConfig | None = None) -> Stream[Any]:
|
|
235
|
+
"""An empty stream."""
|
|
236
|
+
return cls([], config=config)
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def from_queue(
|
|
240
|
+
cls,
|
|
241
|
+
queue: BoundedQueue[T],
|
|
242
|
+
*,
|
|
243
|
+
config: StreamConfig | None = None,
|
|
244
|
+
) -> Stream[T]:
|
|
245
|
+
"""Stream the items of a :class:`BoundedQueue` until it is closed.
|
|
246
|
+
|
|
247
|
+
The stream is single-consumer; the queue lifecycle stays with the caller.
|
|
248
|
+
"""
|
|
249
|
+
if not isinstance(queue, BoundedQueue):
|
|
250
|
+
raise TypeError(f"from_queue() expects a BoundedQueue, got {type(queue).__name__}")
|
|
251
|
+
|
|
252
|
+
async def _drain_queue() -> AsyncIterator[T]:
|
|
253
|
+
async for item in queue:
|
|
254
|
+
yield item
|
|
255
|
+
|
|
256
|
+
return cls(_drain_queue, config=config, replayable=False)
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------ #
|
|
259
|
+
# Introspection
|
|
260
|
+
# ------------------------------------------------------------------ #
|
|
261
|
+
@property
|
|
262
|
+
def config(self) -> StreamConfig:
|
|
263
|
+
return self._config
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def replayable(self) -> bool:
|
|
267
|
+
"""Whether the source can be iterated again (needed by ``retry()``)."""
|
|
268
|
+
return self._replayable
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def consumed(self) -> bool:
|
|
272
|
+
return self._consumed
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def stats(self) -> dict[str, Any]:
|
|
276
|
+
"""Counters for this stream (``observe=True`` collects them)."""
|
|
277
|
+
snapshot = snapshot_stats(self._stats)
|
|
278
|
+
assert snapshot is not None
|
|
279
|
+
return snapshot
|
|
280
|
+
|
|
281
|
+
def _label(self) -> str:
|
|
282
|
+
return f" {self._config.name!r}" if self._config.name else ""
|
|
283
|
+
|
|
284
|
+
def _derive(
|
|
285
|
+
self,
|
|
286
|
+
factory: Callable[[], AsyncIterator[Any]],
|
|
287
|
+
*,
|
|
288
|
+
name: str | None = None,
|
|
289
|
+
replayable: bool | None = None,
|
|
290
|
+
stats: StreamStats | None = None,
|
|
291
|
+
) -> Stream[Any]:
|
|
292
|
+
config = self._config if name is None else replace(self._config, name=name)
|
|
293
|
+
stream: Stream[Any] = Stream(
|
|
294
|
+
factory,
|
|
295
|
+
config=config,
|
|
296
|
+
replayable=self._replayable if replayable is None else replayable,
|
|
297
|
+
)
|
|
298
|
+
if stats is not None:
|
|
299
|
+
stream._stats = stats
|
|
300
|
+
return stream
|
|
301
|
+
|
|
302
|
+
def _open(self) -> StreamIterator[T]:
|
|
303
|
+
if self._consumed:
|
|
304
|
+
raise StreamConsumedError(
|
|
305
|
+
f"stream{self._label()} has already been consumed; streams are "
|
|
306
|
+
"single-consumer. Build a new stream for a second pass, or use "
|
|
307
|
+
"Channel/EventBus for fan-out."
|
|
308
|
+
)
|
|
309
|
+
self._consumed = True
|
|
310
|
+
generator = self._factory()
|
|
311
|
+
iterator: StreamIterator[T] = StreamIterator(
|
|
312
|
+
generator, stats=self._stats if self._config.observe else None
|
|
313
|
+
)
|
|
314
|
+
self._current = iterator
|
|
315
|
+
return iterator
|
|
316
|
+
|
|
317
|
+
def _open_raw(self) -> AsyncIterator[T]:
|
|
318
|
+
"""Internal open used by operators; allows replay for retryable sources."""
|
|
319
|
+
if self._consumed and not self._replayable:
|
|
320
|
+
raise NonReplayableStreamError(
|
|
321
|
+
f"stream{self._label()} is single-use and has already been "
|
|
322
|
+
"consumed; wrap the source with Stream.from_factory() to make it "
|
|
323
|
+
"replayable"
|
|
324
|
+
)
|
|
325
|
+
self._consumed = True
|
|
326
|
+
return self._factory()
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------ #
|
|
329
|
+
# Iteration
|
|
330
|
+
# ------------------------------------------------------------------ #
|
|
331
|
+
def __aiter__(self) -> StreamIterator[T]:
|
|
332
|
+
return self._open()
|
|
333
|
+
|
|
334
|
+
async def __aenter__(self) -> StreamIterator[T]:
|
|
335
|
+
iterator = self._open()
|
|
336
|
+
self._current = iterator
|
|
337
|
+
return iterator
|
|
338
|
+
|
|
339
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
340
|
+
await self.aclose()
|
|
341
|
+
|
|
342
|
+
async def aclose(self) -> None:
|
|
343
|
+
"""Close the active iterator and everything upstream of it."""
|
|
344
|
+
current = self._current
|
|
345
|
+
self._current = None
|
|
346
|
+
if current is not None:
|
|
347
|
+
await current.aclose()
|
|
348
|
+
|
|
349
|
+
def __repr__(self) -> str:
|
|
350
|
+
state = "consumed" if self._consumed else "fresh"
|
|
351
|
+
kind = "replayable" if self._replayable else "one-shot"
|
|
352
|
+
return f"<Stream{self._label()} {kind}, {state}>"
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------ #
|
|
355
|
+
# Terminal operations
|
|
356
|
+
# ------------------------------------------------------------------ #
|
|
357
|
+
async def collect(self) -> list[T]:
|
|
358
|
+
"""Consume the stream and return every item as a list."""
|
|
359
|
+
iterator = self._open()
|
|
360
|
+
items: list[T] = []
|
|
361
|
+
try:
|
|
362
|
+
async for item in iterator:
|
|
363
|
+
items.append(item)
|
|
364
|
+
finally:
|
|
365
|
+
await iterator.aclose()
|
|
366
|
+
return items
|
|
367
|
+
|
|
368
|
+
async def count(self) -> int:
|
|
369
|
+
"""Consume the stream and return the number of items."""
|
|
370
|
+
iterator = self._open()
|
|
371
|
+
total = 0
|
|
372
|
+
try:
|
|
373
|
+
async for _ in iterator:
|
|
374
|
+
total += 1
|
|
375
|
+
finally:
|
|
376
|
+
await iterator.aclose()
|
|
377
|
+
return total
|
|
378
|
+
|
|
379
|
+
async def drain(self) -> int:
|
|
380
|
+
"""Consume and discard every item, returning the count."""
|
|
381
|
+
return await self.count()
|
|
382
|
+
|
|
383
|
+
async def first(self, default: Any = _UNSET) -> Any:
|
|
384
|
+
"""Return the first item.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
EmptyStreamError: The stream is empty and no default was given.
|
|
388
|
+
"""
|
|
389
|
+
iterator = self._open()
|
|
390
|
+
try:
|
|
391
|
+
try:
|
|
392
|
+
return await iterator.__anext__()
|
|
393
|
+
except StopAsyncIteration:
|
|
394
|
+
if default is _UNSET:
|
|
395
|
+
raise EmptyStreamError("stream is empty") from None
|
|
396
|
+
return default
|
|
397
|
+
finally:
|
|
398
|
+
await iterator.aclose()
|
|
399
|
+
|
|
400
|
+
async def last(self, default: Any = _UNSET) -> Any:
|
|
401
|
+
"""Return the last item, consuming the stream."""
|
|
402
|
+
iterator = self._open()
|
|
403
|
+
found: Any = _UNSET
|
|
404
|
+
try:
|
|
405
|
+
async for item in iterator:
|
|
406
|
+
found = item
|
|
407
|
+
finally:
|
|
408
|
+
await iterator.aclose()
|
|
409
|
+
if found is _UNSET:
|
|
410
|
+
if default is _UNSET:
|
|
411
|
+
raise EmptyStreamError("stream is empty")
|
|
412
|
+
return default
|
|
413
|
+
return found
|
|
414
|
+
|
|
415
|
+
async def reduce(self, func: Callable[[Any, T], Any], initial: Any = _UNSET) -> Any:
|
|
416
|
+
"""Fold the stream with ``func(accumulator, item)``.
|
|
417
|
+
|
|
418
|
+
``func`` may be a normal function or a coroutine function. Without an
|
|
419
|
+
initial value the first item seeds the accumulator; an empty stream then
|
|
420
|
+
raises :class:`EmptyStreamError`.
|
|
421
|
+
"""
|
|
422
|
+
if not callable(func):
|
|
423
|
+
raise TypeError(f"reduce() requires a callable, got {type(func).__name__}")
|
|
424
|
+
iterator = self._open()
|
|
425
|
+
accumulator: Any = initial
|
|
426
|
+
try:
|
|
427
|
+
async for item in iterator:
|
|
428
|
+
if accumulator is _UNSET:
|
|
429
|
+
accumulator = item
|
|
430
|
+
else:
|
|
431
|
+
accumulator = await maybe_await(func(accumulator, item))
|
|
432
|
+
finally:
|
|
433
|
+
await iterator.aclose()
|
|
434
|
+
if accumulator is _UNSET:
|
|
435
|
+
raise EmptyStreamError("cannot reduce an empty stream without an initial value")
|
|
436
|
+
return accumulator
|
|
437
|
+
|
|
438
|
+
# ------------------------------------------------------------------ #
|
|
439
|
+
# Operators
|
|
440
|
+
# ------------------------------------------------------------------ #
|
|
441
|
+
def map(
|
|
442
|
+
self,
|
|
443
|
+
func: Callable[[T], Any],
|
|
444
|
+
*,
|
|
445
|
+
concurrency: int | None = None,
|
|
446
|
+
ordered: bool = True,
|
|
447
|
+
name: str | None = None,
|
|
448
|
+
) -> Stream[Any]:
|
|
449
|
+
"""Transform every item; callbacks may be async.
|
|
450
|
+
|
|
451
|
+
With ``concurrency > 1`` up to that many callbacks run at once while
|
|
452
|
+
order is preserved (``ordered=False`` yields completion order).
|
|
453
|
+
"""
|
|
454
|
+
return ops.op_map(self, func, concurrency=concurrency, ordered=ordered, name=name)
|
|
455
|
+
|
|
456
|
+
def filter(self, predicate: Callable[[T], Any], *, name: str | None = None) -> Stream[T]:
|
|
457
|
+
"""Keep items for which ``predicate`` is truthy."""
|
|
458
|
+
return ops.op_filter(self, predicate, name=name)
|
|
459
|
+
|
|
460
|
+
def flat_map(self, func: Callable[[T], Any], *, name: str | None = None) -> Stream[Any]:
|
|
461
|
+
"""Map each item to an iterable and flatten the results in order."""
|
|
462
|
+
return ops.op_flat_map(self, func, name=name)
|
|
463
|
+
|
|
464
|
+
def take(self, count: int, *, name: str | None = None) -> Stream[T]:
|
|
465
|
+
"""Yield at most ``count`` items, then close upstream."""
|
|
466
|
+
return ops.op_take(self, count, name=name)
|
|
467
|
+
|
|
468
|
+
def skip(self, count: int, *, name: str | None = None) -> Stream[T]:
|
|
469
|
+
"""Discard the first ``count`` items."""
|
|
470
|
+
return ops.op_skip(self, count, name=name)
|
|
471
|
+
|
|
472
|
+
def batch(
|
|
473
|
+
self,
|
|
474
|
+
size: int,
|
|
475
|
+
*,
|
|
476
|
+
partial: bool = True,
|
|
477
|
+
name: str | None = None,
|
|
478
|
+
) -> Stream[list[T]]:
|
|
479
|
+
"""Group items into lists of ``size`` (the last one may be partial)."""
|
|
480
|
+
return ops.op_batch(self, size, partial=partial, name=name)
|
|
481
|
+
|
|
482
|
+
def buffer(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
size: int | None = None,
|
|
486
|
+
overflow: OverflowPolicy | str | None = None,
|
|
487
|
+
name: str | None = None,
|
|
488
|
+
) -> Stream[T]:
|
|
489
|
+
"""Insert a bounded, backpressured buffer between producer and consumer."""
|
|
490
|
+
return ops.op_buffer(self, size=size, overflow=overflow, name=name)
|
|
491
|
+
|
|
492
|
+
def distinct(
|
|
493
|
+
self,
|
|
494
|
+
key: Callable[[T], Any] | None = None,
|
|
495
|
+
*,
|
|
496
|
+
capacity: int | None = None,
|
|
497
|
+
name: str | None = None,
|
|
498
|
+
) -> Stream[T]:
|
|
499
|
+
"""Drop duplicates, optionally remembering only the last ``capacity`` keys."""
|
|
500
|
+
return ops.op_distinct(self, key, capacity=capacity, name=name)
|
|
501
|
+
|
|
502
|
+
def enumerate(self, start: int = 0, *, name: str | None = None) -> Stream[tuple[int, T]]:
|
|
503
|
+
"""Pair every item with its index."""
|
|
504
|
+
return ops.op_enumerate(self, start, name=name)
|
|
505
|
+
|
|
506
|
+
def window(self, size: int, *, step: int = 1, name: str | None = None) -> Stream[list[T]]:
|
|
507
|
+
"""Yield sliding windows of ``size`` items, advancing by ``step``."""
|
|
508
|
+
return ops.op_window(self, size, step=step, name=name)
|
|
509
|
+
|
|
510
|
+
def debounce(self, seconds: float, *, name: str | None = None) -> Stream[T]:
|
|
511
|
+
"""Emit an item after ``seconds`` of silence."""
|
|
512
|
+
return ops.op_debounce(self, seconds, name=name)
|
|
513
|
+
|
|
514
|
+
def throttle(self, interval: float, *, name: str | None = None) -> Stream[T]:
|
|
515
|
+
"""Emit at most one item per ``interval`` seconds."""
|
|
516
|
+
return ops.op_throttle(self, interval, name=name)
|
|
517
|
+
|
|
518
|
+
def timeout(self, seconds: float, *, name: str | None = None) -> Stream[T]:
|
|
519
|
+
"""Fail with :class:`StreamTimeoutError` if no item arrives in time."""
|
|
520
|
+
return ops.op_timeout(self, seconds, name=name)
|
|
521
|
+
|
|
522
|
+
def retry(
|
|
523
|
+
self,
|
|
524
|
+
policy: RetryPolicy | int | dict[str, Any] | None = None,
|
|
525
|
+
*,
|
|
526
|
+
on: Any = None,
|
|
527
|
+
name: str | None = None,
|
|
528
|
+
) -> Stream[T]:
|
|
529
|
+
"""Restart a replayable upstream when it fails."""
|
|
530
|
+
return ops.op_retry(self, policy, on=on, name=name)
|
|
531
|
+
|
|
532
|
+
def recover(self, handler: Callable[[Exception], Any], *, name: str | None = None) -> Stream[T]:
|
|
533
|
+
"""Replace a failed upstream with items returned by ``handler``."""
|
|
534
|
+
return ops.op_recover(self, handler, name=name)
|
|
535
|
+
|
|
536
|
+
def tap(self, func: Callable[[T], Any], *, name: str | None = None) -> Stream[T]:
|
|
537
|
+
"""Run a side effect for every item without changing the items."""
|
|
538
|
+
return ops.op_tap(self, func, name=name)
|
|
539
|
+
|
|
540
|
+
def zip(self, *others: Any, name: str | None = None) -> Stream[tuple[Any, ...]]:
|
|
541
|
+
"""Combine streams positionally; stops when the first one ends."""
|
|
542
|
+
return ops.op_zip(self, *others, name=name)
|
|
543
|
+
|
|
544
|
+
def merge(
|
|
545
|
+
self,
|
|
546
|
+
*others: Any,
|
|
547
|
+
buffer_size: int | None = None,
|
|
548
|
+
overflow: OverflowPolicy | str | None = None,
|
|
549
|
+
name: str | None = None,
|
|
550
|
+
) -> Stream[Any]:
|
|
551
|
+
"""Interleave streams concurrently and yield items as they arrive."""
|
|
552
|
+
return ops.op_merge(
|
|
553
|
+
self,
|
|
554
|
+
*others,
|
|
555
|
+
buffer_size=buffer_size,
|
|
556
|
+
overflow=overflow,
|
|
557
|
+
name=name,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def concat(self, *others: Any, name: str | None = None) -> Stream[Any]:
|
|
561
|
+
"""Consume streams one after another."""
|
|
562
|
+
return ops.op_concat(self, *others, name=name)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _is_async_iterable(value: Any) -> bool:
|
|
566
|
+
return hasattr(value, "__aiter__")
|