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,844 @@
|
|
|
1
|
+
"""Lazy, cancellation-safe stream operators.
|
|
2
|
+
|
|
3
|
+
Every operator returns a new :class:`~datacurrent.Stream` backed by an async
|
|
4
|
+
generator factory. Opening the derived stream pulls from the upstream exactly
|
|
5
|
+
once, and closing it closes every upstream iterator, so ``break``, exceptions
|
|
6
|
+
and task cancellation never leak generators or background tasks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import AsyncIterator, Callable, Iterable
|
|
14
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
15
|
+
|
|
16
|
+
from ..buffering.backpressure import BufferPolicy
|
|
17
|
+
from ..buffering.queue import BoundedQueue
|
|
18
|
+
from ..config import OverflowPolicy
|
|
19
|
+
from ..exceptions import (
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
NonReplayableStreamError,
|
|
22
|
+
QueueClosedError,
|
|
23
|
+
StreamTimeoutError,
|
|
24
|
+
)
|
|
25
|
+
from ..observability.metrics import StreamStats
|
|
26
|
+
from ..reliability.retry import RetryPolicy, is_retryable
|
|
27
|
+
from ..reliability.timeout import validate_timeout
|
|
28
|
+
from ._util import aclose, as_aiterator, maybe_await
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .stream import Stream
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
U = TypeVar("U")
|
|
35
|
+
_UNSET = object()
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"op_batch",
|
|
39
|
+
"op_buffer",
|
|
40
|
+
"op_concat",
|
|
41
|
+
"op_debounce",
|
|
42
|
+
"op_distinct",
|
|
43
|
+
"op_enumerate",
|
|
44
|
+
"op_filter",
|
|
45
|
+
"op_flat_map",
|
|
46
|
+
"op_map",
|
|
47
|
+
"op_merge",
|
|
48
|
+
"op_recover",
|
|
49
|
+
"op_retry",
|
|
50
|
+
"op_skip",
|
|
51
|
+
"op_take",
|
|
52
|
+
"op_tap",
|
|
53
|
+
"op_throttle",
|
|
54
|
+
"op_timeout",
|
|
55
|
+
"op_window",
|
|
56
|
+
"op_zip",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Helpers
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
def _require_callable(func: Any, operator: str) -> Callable[..., Any]:
|
|
64
|
+
if not callable(func):
|
|
65
|
+
raise TypeError(f"{operator}() requires a callable, got {type(func).__name__}")
|
|
66
|
+
return func
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_int(value: Any, name: str, *, minimum: int) -> int:
|
|
70
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
71
|
+
raise ConfigurationError(f"{name} must be an integer, got {type(value).__name__}")
|
|
72
|
+
if value < minimum:
|
|
73
|
+
raise ConfigurationError(f"{name} must be >= {minimum}, got {value}")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _next_item(iterator: AsyncIterator[Any]) -> Any:
|
|
78
|
+
"""Await the next item from an async iterator (typed for create_task)."""
|
|
79
|
+
return await iterator.__anext__()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_stream(value: Any, config: Any) -> Stream[Any]:
|
|
83
|
+
"""Coerce a Stream, iterable or async iterable into a Stream."""
|
|
84
|
+
from .stream import Stream
|
|
85
|
+
|
|
86
|
+
if isinstance(value, Stream):
|
|
87
|
+
return value
|
|
88
|
+
return Stream(value, config=config)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
# map / filter / flat_map / tap
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
def op_map(
|
|
95
|
+
upstream: Stream[T],
|
|
96
|
+
func: Callable[[T], Any],
|
|
97
|
+
*,
|
|
98
|
+
concurrency: int | None = None,
|
|
99
|
+
ordered: bool = True,
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
) -> Stream[Any]:
|
|
102
|
+
_require_callable(func, "map")
|
|
103
|
+
limit = (
|
|
104
|
+
upstream.config.concurrency
|
|
105
|
+
if concurrency is None
|
|
106
|
+
else _require_int(concurrency, "concurrency", minimum=1)
|
|
107
|
+
)
|
|
108
|
+
if not isinstance(ordered, bool):
|
|
109
|
+
raise ConfigurationError("ordered must be a bool")
|
|
110
|
+
|
|
111
|
+
def factory() -> AsyncIterator[Any]:
|
|
112
|
+
return _map_gen(upstream, func, limit, ordered)
|
|
113
|
+
|
|
114
|
+
return upstream._derive(factory, name=name)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _map_gen(
|
|
118
|
+
upstream: Stream[Any],
|
|
119
|
+
func: Callable[[Any], Any],
|
|
120
|
+
limit: int,
|
|
121
|
+
ordered: bool,
|
|
122
|
+
) -> AsyncIterator[Any]:
|
|
123
|
+
source = upstream._open_raw()
|
|
124
|
+
try:
|
|
125
|
+
if limit == 1:
|
|
126
|
+
async for item in source:
|
|
127
|
+
yield await maybe_await(func(item))
|
|
128
|
+
else:
|
|
129
|
+
async for result in _concurrent_map(source, func, limit, ordered):
|
|
130
|
+
yield result
|
|
131
|
+
finally:
|
|
132
|
+
await aclose(source)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _invoke(func: Callable[[Any], Any], item: Any) -> Any:
|
|
136
|
+
"""Run a callback inside a task.
|
|
137
|
+
|
|
138
|
+
The inner awaitable is created only when the task actually starts, so
|
|
139
|
+
cancelling a task before its first step can never leave an un-awaited
|
|
140
|
+
coroutine behind.
|
|
141
|
+
"""
|
|
142
|
+
return await maybe_await(func(item))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _concurrent_map(
|
|
146
|
+
source: AsyncIterator[Any],
|
|
147
|
+
func: Callable[[Any], Any],
|
|
148
|
+
limit: int,
|
|
149
|
+
ordered: bool,
|
|
150
|
+
) -> AsyncIterator[Any]:
|
|
151
|
+
tasks: deque[asyncio.Task[Any]] = deque()
|
|
152
|
+
exhausted = False
|
|
153
|
+
try:
|
|
154
|
+
while len(tasks) < limit and not exhausted:
|
|
155
|
+
try:
|
|
156
|
+
item = await source.__anext__()
|
|
157
|
+
except StopAsyncIteration:
|
|
158
|
+
exhausted = True
|
|
159
|
+
break
|
|
160
|
+
tasks.append(asyncio.create_task(_invoke(func, item)))
|
|
161
|
+
while tasks:
|
|
162
|
+
if ordered:
|
|
163
|
+
yield await tasks.popleft()
|
|
164
|
+
else:
|
|
165
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
166
|
+
for task in done:
|
|
167
|
+
tasks.remove(task)
|
|
168
|
+
yield task.result()
|
|
169
|
+
if not exhausted:
|
|
170
|
+
try:
|
|
171
|
+
item = await source.__anext__()
|
|
172
|
+
except StopAsyncIteration:
|
|
173
|
+
exhausted = True
|
|
174
|
+
else:
|
|
175
|
+
tasks.append(asyncio.create_task(_invoke(func, item)))
|
|
176
|
+
finally:
|
|
177
|
+
for task in tasks:
|
|
178
|
+
task.cancel()
|
|
179
|
+
if tasks:
|
|
180
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def op_filter(
|
|
184
|
+
upstream: Stream[T],
|
|
185
|
+
predicate: Callable[[T], Any],
|
|
186
|
+
*,
|
|
187
|
+
name: str | None = None,
|
|
188
|
+
) -> Stream[T]:
|
|
189
|
+
_require_callable(predicate, "filter")
|
|
190
|
+
|
|
191
|
+
def factory() -> AsyncIterator[T]:
|
|
192
|
+
return _filter_gen(upstream, predicate)
|
|
193
|
+
|
|
194
|
+
return upstream._derive(factory, name=name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _filter_gen(upstream: Stream[T], predicate: Callable[[T], Any]) -> AsyncIterator[T]:
|
|
198
|
+
source = upstream._open_raw()
|
|
199
|
+
try:
|
|
200
|
+
async for item in source:
|
|
201
|
+
if await maybe_await(predicate(item)):
|
|
202
|
+
yield item
|
|
203
|
+
finally:
|
|
204
|
+
await aclose(source)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def op_flat_map(
|
|
208
|
+
upstream: Stream[T],
|
|
209
|
+
func: Callable[[T], Any],
|
|
210
|
+
*,
|
|
211
|
+
name: str | None = None,
|
|
212
|
+
) -> Stream[Any]:
|
|
213
|
+
_require_callable(func, "flat_map")
|
|
214
|
+
|
|
215
|
+
def factory() -> AsyncIterator[Any]:
|
|
216
|
+
return _flat_map_gen(upstream, func)
|
|
217
|
+
|
|
218
|
+
return upstream._derive(factory, name=name)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _flat_map_gen(upstream: Stream[T], func: Callable[[T], Any]) -> AsyncIterator[Any]:
|
|
222
|
+
source = upstream._open_raw()
|
|
223
|
+
try:
|
|
224
|
+
async for item in source:
|
|
225
|
+
produced = await maybe_await(func(item))
|
|
226
|
+
if produced is None:
|
|
227
|
+
continue
|
|
228
|
+
if isinstance(produced, (str, bytes, bytearray)):
|
|
229
|
+
raise TypeError(
|
|
230
|
+
"flat_map() callback must return an iterable of items, not "
|
|
231
|
+
f"{type(produced).__name__}; wrap single values in a list"
|
|
232
|
+
)
|
|
233
|
+
inner = as_aiterator(produced)
|
|
234
|
+
try:
|
|
235
|
+
async for value in inner:
|
|
236
|
+
yield value
|
|
237
|
+
finally:
|
|
238
|
+
await aclose(inner)
|
|
239
|
+
finally:
|
|
240
|
+
await aclose(source)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def op_tap(
|
|
244
|
+
upstream: Stream[T],
|
|
245
|
+
func: Callable[[T], Any],
|
|
246
|
+
*,
|
|
247
|
+
name: str | None = None,
|
|
248
|
+
) -> Stream[T]:
|
|
249
|
+
"""Observe items without changing them; side effects may be async."""
|
|
250
|
+
_require_callable(func, "tap")
|
|
251
|
+
|
|
252
|
+
def factory() -> AsyncIterator[T]:
|
|
253
|
+
return _tap_gen(upstream, func)
|
|
254
|
+
|
|
255
|
+
return upstream._derive(factory, name=name)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _tap_gen(upstream: Stream[T], func: Callable[[T], Any]) -> AsyncIterator[T]:
|
|
259
|
+
source = upstream._open_raw()
|
|
260
|
+
try:
|
|
261
|
+
async for item in source:
|
|
262
|
+
await maybe_await(func(item))
|
|
263
|
+
yield item
|
|
264
|
+
finally:
|
|
265
|
+
await aclose(source)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------- #
|
|
269
|
+
# take / skip / batch / buffer
|
|
270
|
+
# --------------------------------------------------------------------------- #
|
|
271
|
+
def op_take(upstream: Stream[T], count: int, *, name: str | None = None) -> Stream[T]:
|
|
272
|
+
limit = _require_int(count, "count", minimum=0)
|
|
273
|
+
|
|
274
|
+
def factory() -> AsyncIterator[T]:
|
|
275
|
+
return _take_gen(upstream, limit)
|
|
276
|
+
|
|
277
|
+
return upstream._derive(factory, name=name)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def _take_gen(upstream: Stream[T], limit: int) -> AsyncIterator[T]:
|
|
281
|
+
if limit == 0:
|
|
282
|
+
return
|
|
283
|
+
source = upstream._open_raw()
|
|
284
|
+
try:
|
|
285
|
+
seen = 0
|
|
286
|
+
async for item in source:
|
|
287
|
+
yield item
|
|
288
|
+
seen += 1
|
|
289
|
+
if seen >= limit:
|
|
290
|
+
return
|
|
291
|
+
finally:
|
|
292
|
+
await aclose(source)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def op_skip(upstream: Stream[T], count: int, *, name: str | None = None) -> Stream[T]:
|
|
296
|
+
to_skip = _require_int(count, "count", minimum=0)
|
|
297
|
+
|
|
298
|
+
def factory() -> AsyncIterator[T]:
|
|
299
|
+
return _skip_gen(upstream, to_skip)
|
|
300
|
+
|
|
301
|
+
return upstream._derive(factory, name=name)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _skip_gen(upstream: Stream[T], count: int) -> AsyncIterator[T]:
|
|
305
|
+
source = upstream._open_raw()
|
|
306
|
+
try:
|
|
307
|
+
seen = 0
|
|
308
|
+
async for item in source:
|
|
309
|
+
if seen < count:
|
|
310
|
+
seen += 1
|
|
311
|
+
continue
|
|
312
|
+
yield item
|
|
313
|
+
finally:
|
|
314
|
+
await aclose(source)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def op_batch(
|
|
318
|
+
upstream: Stream[T],
|
|
319
|
+
size: int,
|
|
320
|
+
*,
|
|
321
|
+
partial: bool = True,
|
|
322
|
+
name: str | None = None,
|
|
323
|
+
) -> Stream[list[T]]:
|
|
324
|
+
batch_size = _require_int(size, "size", minimum=1)
|
|
325
|
+
if not isinstance(partial, bool):
|
|
326
|
+
raise ConfigurationError("partial must be a bool")
|
|
327
|
+
|
|
328
|
+
def factory() -> AsyncIterator[list[T]]:
|
|
329
|
+
return _batch_gen(upstream, batch_size, partial)
|
|
330
|
+
|
|
331
|
+
return upstream._derive(factory, name=name)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def _batch_gen(upstream: Stream[T], size: int, partial: bool) -> AsyncIterator[list[T]]:
|
|
335
|
+
source = upstream._open_raw()
|
|
336
|
+
chunk: list[T] = []
|
|
337
|
+
try:
|
|
338
|
+
async for item in source:
|
|
339
|
+
chunk.append(item)
|
|
340
|
+
if len(chunk) >= size:
|
|
341
|
+
yield chunk
|
|
342
|
+
chunk = []
|
|
343
|
+
if chunk and partial:
|
|
344
|
+
yield chunk
|
|
345
|
+
finally:
|
|
346
|
+
await aclose(source)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def op_buffer(
|
|
350
|
+
upstream: Stream[T],
|
|
351
|
+
*,
|
|
352
|
+
size: int | None = None,
|
|
353
|
+
overflow: OverflowPolicy | str | None = None,
|
|
354
|
+
name: str | None = None,
|
|
355
|
+
) -> Stream[T]:
|
|
356
|
+
"""Decouple producer and consumer through a bounded queue.
|
|
357
|
+
|
|
358
|
+
The producer runs in one background task and suspends when the buffer is
|
|
359
|
+
full (``BLOCK``), which is how a slow consumer applies real backpressure
|
|
360
|
+
instead of growing memory without limit.
|
|
361
|
+
"""
|
|
362
|
+
policy = BufferPolicy.from_config(upstream.config, size=size, overflow=overflow)
|
|
363
|
+
stats = StreamStats(name=name or upstream.config.name)
|
|
364
|
+
|
|
365
|
+
def factory() -> AsyncIterator[T]:
|
|
366
|
+
return _buffer_gen(upstream, policy, stats)
|
|
367
|
+
|
|
368
|
+
return upstream._derive(factory, name=name, stats=stats)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def _buffer_gen(
|
|
372
|
+
upstream: Stream[T],
|
|
373
|
+
policy: BufferPolicy,
|
|
374
|
+
stats: StreamStats,
|
|
375
|
+
) -> AsyncIterator[T]:
|
|
376
|
+
def record_drop() -> None:
|
|
377
|
+
stats.dropped += 1
|
|
378
|
+
|
|
379
|
+
queue: BoundedQueue[T] = policy.build(name=upstream.config.name, on_drop=record_drop)
|
|
380
|
+
source = upstream._open_raw()
|
|
381
|
+
|
|
382
|
+
async def produce() -> None:
|
|
383
|
+
try:
|
|
384
|
+
async for item in source:
|
|
385
|
+
await queue.put(item)
|
|
386
|
+
except asyncio.CancelledError:
|
|
387
|
+
raise
|
|
388
|
+
except Exception as error:
|
|
389
|
+
await queue.fail(error)
|
|
390
|
+
finally:
|
|
391
|
+
await queue.aclose()
|
|
392
|
+
|
|
393
|
+
producer = asyncio.create_task(produce(), name="datacurrent-buffer")
|
|
394
|
+
try:
|
|
395
|
+
async for item in queue:
|
|
396
|
+
yield item
|
|
397
|
+
finally:
|
|
398
|
+
producer.cancel()
|
|
399
|
+
await asyncio.gather(producer, return_exceptions=True)
|
|
400
|
+
await queue.aclose()
|
|
401
|
+
await aclose(source)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --------------------------------------------------------------------------- #
|
|
405
|
+
# distinct / enumerate / window / debounce / throttle
|
|
406
|
+
# --------------------------------------------------------------------------- #
|
|
407
|
+
def op_distinct(
|
|
408
|
+
upstream: Stream[T],
|
|
409
|
+
key: Callable[[T], Any] | None = None,
|
|
410
|
+
*,
|
|
411
|
+
capacity: int | None = None,
|
|
412
|
+
name: str | None = None,
|
|
413
|
+
) -> Stream[T]:
|
|
414
|
+
if key is not None:
|
|
415
|
+
_require_callable(key, "distinct")
|
|
416
|
+
if capacity is not None:
|
|
417
|
+
_require_int(capacity, "capacity", minimum=1)
|
|
418
|
+
|
|
419
|
+
def factory() -> AsyncIterator[T]:
|
|
420
|
+
return _distinct_gen(upstream, key, capacity)
|
|
421
|
+
|
|
422
|
+
return upstream._derive(factory, name=name)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def _distinct_gen(
|
|
426
|
+
upstream: Stream[T],
|
|
427
|
+
key: Callable[[T], Any] | None,
|
|
428
|
+
capacity: int | None,
|
|
429
|
+
) -> AsyncIterator[T]:
|
|
430
|
+
seen: set[Any] = set()
|
|
431
|
+
order: deque[Any] = deque()
|
|
432
|
+
source = upstream._open_raw()
|
|
433
|
+
try:
|
|
434
|
+
async for item in source:
|
|
435
|
+
marker = await maybe_await(key(item)) if key is not None else item
|
|
436
|
+
if marker in seen:
|
|
437
|
+
continue
|
|
438
|
+
seen.add(marker)
|
|
439
|
+
order.append(marker)
|
|
440
|
+
if capacity is not None and len(order) > capacity:
|
|
441
|
+
seen.discard(order.popleft())
|
|
442
|
+
yield item
|
|
443
|
+
finally:
|
|
444
|
+
await aclose(source)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def op_enumerate(
|
|
448
|
+
upstream: Stream[T], start: int = 0, *, name: str | None = None
|
|
449
|
+
) -> Stream[tuple[int, T]]:
|
|
450
|
+
_require_int(start, "start", minimum=-(2**63))
|
|
451
|
+
|
|
452
|
+
def factory() -> AsyncIterator[tuple[int, T]]:
|
|
453
|
+
return _enumerate_gen(upstream, start)
|
|
454
|
+
|
|
455
|
+
return upstream._derive(factory, name=name)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def _enumerate_gen(upstream: Stream[T], start: int) -> AsyncIterator[tuple[int, T]]:
|
|
459
|
+
source = upstream._open_raw()
|
|
460
|
+
try:
|
|
461
|
+
index = start
|
|
462
|
+
async for item in source:
|
|
463
|
+
yield index, item
|
|
464
|
+
index += 1
|
|
465
|
+
finally:
|
|
466
|
+
await aclose(source)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def op_window(
|
|
470
|
+
upstream: Stream[T],
|
|
471
|
+
size: int,
|
|
472
|
+
*,
|
|
473
|
+
step: int = 1,
|
|
474
|
+
name: str | None = None,
|
|
475
|
+
) -> Stream[list[T]]:
|
|
476
|
+
window_size = _require_int(size, "size", minimum=1)
|
|
477
|
+
step_size = _require_int(step, "step", minimum=1)
|
|
478
|
+
|
|
479
|
+
def factory() -> AsyncIterator[list[T]]:
|
|
480
|
+
return _window_gen(upstream, window_size, step_size)
|
|
481
|
+
|
|
482
|
+
return upstream._derive(factory, name=name)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def _window_gen(upstream: Stream[T], size: int, step: int) -> AsyncIterator[list[T]]:
|
|
486
|
+
buffer: deque[T] = deque(maxlen=size)
|
|
487
|
+
source = upstream._open_raw()
|
|
488
|
+
try:
|
|
489
|
+
async for item in source:
|
|
490
|
+
buffer.append(item)
|
|
491
|
+
if len(buffer) == size:
|
|
492
|
+
yield list(buffer)
|
|
493
|
+
for _ in range(min(step, len(buffer))):
|
|
494
|
+
buffer.popleft()
|
|
495
|
+
finally:
|
|
496
|
+
await aclose(source)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def op_debounce(
|
|
500
|
+
upstream: Stream[T],
|
|
501
|
+
seconds: float,
|
|
502
|
+
*,
|
|
503
|
+
name: str | None = None,
|
|
504
|
+
) -> Stream[T]:
|
|
505
|
+
"""Emit an item once no newer item arrived for ``seconds``."""
|
|
506
|
+
interval = validate_timeout(seconds, name="debounce interval")
|
|
507
|
+
|
|
508
|
+
def factory() -> AsyncIterator[T]:
|
|
509
|
+
return _debounce_gen(upstream, interval)
|
|
510
|
+
|
|
511
|
+
return upstream._derive(factory, name=name)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
async def _debounce_gen(upstream: Stream[T], seconds: float) -> AsyncIterator[T]:
|
|
515
|
+
source = upstream._open_raw()
|
|
516
|
+
iterator = source.__aiter__()
|
|
517
|
+
pending: Any = _UNSET
|
|
518
|
+
pull = asyncio.create_task(_next_item(iterator), name="datacurrent-debounce")
|
|
519
|
+
try:
|
|
520
|
+
while True:
|
|
521
|
+
timeout = None if pending is _UNSET else seconds
|
|
522
|
+
done, _ = await asyncio.wait({pull}, timeout=timeout)
|
|
523
|
+
if not done:
|
|
524
|
+
yield pending
|
|
525
|
+
pending = _UNSET
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
item = pull.result()
|
|
529
|
+
except StopAsyncIteration:
|
|
530
|
+
if pending is not _UNSET:
|
|
531
|
+
yield pending
|
|
532
|
+
return
|
|
533
|
+
pending = item
|
|
534
|
+
pull = asyncio.create_task(_next_item(iterator), name="datacurrent-debounce")
|
|
535
|
+
finally:
|
|
536
|
+
if not pull.done():
|
|
537
|
+
pull.cancel()
|
|
538
|
+
await asyncio.gather(pull, return_exceptions=True)
|
|
539
|
+
await aclose(source)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def op_throttle(
|
|
543
|
+
upstream: Stream[T],
|
|
544
|
+
interval: float,
|
|
545
|
+
*,
|
|
546
|
+
name: str | None = None,
|
|
547
|
+
) -> Stream[T]:
|
|
548
|
+
"""Emit at most one item per ``interval`` seconds (leading edge).
|
|
549
|
+
|
|
550
|
+
Items that arrive too soon are consumed and dropped; use ``buffer()`` if you
|
|
551
|
+
need to keep them.
|
|
552
|
+
"""
|
|
553
|
+
spacing = validate_timeout(interval, name="throttle interval")
|
|
554
|
+
|
|
555
|
+
def factory() -> AsyncIterator[T]:
|
|
556
|
+
return _throttle_gen(upstream, spacing)
|
|
557
|
+
|
|
558
|
+
return upstream._derive(factory, name=name)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
async def _throttle_gen(upstream: Stream[T], interval: float) -> AsyncIterator[T]:
|
|
562
|
+
source = upstream._open_raw()
|
|
563
|
+
loop = asyncio.get_running_loop()
|
|
564
|
+
last_emit: float | None = None
|
|
565
|
+
try:
|
|
566
|
+
async for item in source:
|
|
567
|
+
now = loop.time()
|
|
568
|
+
if last_emit is None or (now - last_emit) >= interval:
|
|
569
|
+
last_emit = now
|
|
570
|
+
yield item
|
|
571
|
+
finally:
|
|
572
|
+
await aclose(source)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# --------------------------------------------------------------------------- #
|
|
576
|
+
# timeout / retry / recover
|
|
577
|
+
# --------------------------------------------------------------------------- #
|
|
578
|
+
def op_timeout(
|
|
579
|
+
upstream: Stream[T],
|
|
580
|
+
seconds: float,
|
|
581
|
+
*,
|
|
582
|
+
name: str | None = None,
|
|
583
|
+
) -> Stream[T]:
|
|
584
|
+
timeout = validate_timeout(seconds)
|
|
585
|
+
|
|
586
|
+
def factory() -> AsyncIterator[T]:
|
|
587
|
+
return _timeout_gen(upstream, timeout)
|
|
588
|
+
|
|
589
|
+
return upstream._derive(factory, name=name)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def _timeout_gen(upstream: Stream[T], deadline: float) -> AsyncIterator[T]:
|
|
593
|
+
source = upstream._open_raw()
|
|
594
|
+
iterator = source.__aiter__()
|
|
595
|
+
try:
|
|
596
|
+
while True:
|
|
597
|
+
try:
|
|
598
|
+
item = await asyncio.wait_for(iterator.__anext__(), timeout=deadline)
|
|
599
|
+
except StopAsyncIteration:
|
|
600
|
+
return
|
|
601
|
+
except TimeoutError:
|
|
602
|
+
raise StreamTimeoutError(f"stream produced no item within {deadline:g}s") from None
|
|
603
|
+
yield item
|
|
604
|
+
finally:
|
|
605
|
+
await aclose(source)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def op_retry(
|
|
609
|
+
upstream: Stream[T],
|
|
610
|
+
policy: RetryPolicy | int | dict[str, Any] | None = None,
|
|
611
|
+
*,
|
|
612
|
+
on: Any = None,
|
|
613
|
+
name: str | None = None,
|
|
614
|
+
) -> Stream[T]:
|
|
615
|
+
"""Restart the upstream stream when it fails.
|
|
616
|
+
|
|
617
|
+
Only replayable sources can be retried. ``Stream.from_iterable()`` on a
|
|
618
|
+
list, ``Stream.from_factory()`` and ``Stream.from_async_iterable()`` on a
|
|
619
|
+
list are replayable; a bare generator or async generator is not, because it
|
|
620
|
+
cannot be restarted.
|
|
621
|
+
"""
|
|
622
|
+
resolved = RetryPolicy.coerce(policy)
|
|
623
|
+
predicate = resolved.retry_on if on is None else on
|
|
624
|
+
stats = StreamStats(name=name or upstream.config.name)
|
|
625
|
+
|
|
626
|
+
def factory() -> AsyncIterator[T]:
|
|
627
|
+
return _retry_gen(upstream, resolved, predicate, stats)
|
|
628
|
+
|
|
629
|
+
return upstream._derive(
|
|
630
|
+
factory,
|
|
631
|
+
name=name,
|
|
632
|
+
replayable=upstream.replayable,
|
|
633
|
+
stats=stats,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
async def _retry_gen(
|
|
638
|
+
upstream: Stream[T],
|
|
639
|
+
policy: RetryPolicy,
|
|
640
|
+
predicate: Any,
|
|
641
|
+
stats: StreamStats,
|
|
642
|
+
) -> AsyncIterator[T]:
|
|
643
|
+
attempt = 1
|
|
644
|
+
while True:
|
|
645
|
+
source = upstream._open_raw()
|
|
646
|
+
try:
|
|
647
|
+
async for item in source:
|
|
648
|
+
yield item
|
|
649
|
+
return
|
|
650
|
+
except asyncio.CancelledError:
|
|
651
|
+
raise
|
|
652
|
+
except Exception as error:
|
|
653
|
+
if attempt >= policy.max_attempts or not is_retryable(predicate, error):
|
|
654
|
+
raise
|
|
655
|
+
if not upstream.replayable:
|
|
656
|
+
raise NonReplayableStreamError(
|
|
657
|
+
"retry() needs a replayable stream. Use Stream.from_factory(), "
|
|
658
|
+
"Stream.from_iterable(list) or Stream.from_async_iterable(list); "
|
|
659
|
+
"a bare generator or async generator is consumed once."
|
|
660
|
+
) from error
|
|
661
|
+
attempt += 1
|
|
662
|
+
stats.retries += 1
|
|
663
|
+
delay = policy.delay_for(attempt - 1)
|
|
664
|
+
if delay:
|
|
665
|
+
await asyncio.sleep(delay)
|
|
666
|
+
finally:
|
|
667
|
+
await aclose(source)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def op_recover(
|
|
671
|
+
upstream: Stream[T],
|
|
672
|
+
handler: Callable[[Exception], Any],
|
|
673
|
+
*,
|
|
674
|
+
name: str | None = None,
|
|
675
|
+
) -> Stream[T]:
|
|
676
|
+
"""Recover from an upstream failure with a replacement iterable.
|
|
677
|
+
|
|
678
|
+
``handler`` receives the exception and may return an iterable, async
|
|
679
|
+
iterable, ``Stream`` or ``None`` (recover with no further items). Exceptions
|
|
680
|
+
raised by the handler propagate.
|
|
681
|
+
"""
|
|
682
|
+
_require_callable(handler, "recover")
|
|
683
|
+
|
|
684
|
+
def factory() -> AsyncIterator[T]:
|
|
685
|
+
return _recover_gen(upstream, handler)
|
|
686
|
+
|
|
687
|
+
return upstream._derive(factory, name=name)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
async def _recover_gen(
|
|
691
|
+
upstream: Stream[T], handler: Callable[[Exception], Any]
|
|
692
|
+
) -> AsyncIterator[T]:
|
|
693
|
+
source = upstream._open_raw()
|
|
694
|
+
replacement: Any = _UNSET
|
|
695
|
+
try:
|
|
696
|
+
async for item in source:
|
|
697
|
+
yield item
|
|
698
|
+
return
|
|
699
|
+
except asyncio.CancelledError:
|
|
700
|
+
raise
|
|
701
|
+
except Exception as error:
|
|
702
|
+
replacement = await maybe_await(handler(error))
|
|
703
|
+
finally:
|
|
704
|
+
await aclose(source)
|
|
705
|
+
|
|
706
|
+
if replacement is _UNSET or replacement is None:
|
|
707
|
+
return
|
|
708
|
+
inner = as_aiterator(replacement)
|
|
709
|
+
try:
|
|
710
|
+
async for item in inner:
|
|
711
|
+
yield item
|
|
712
|
+
finally:
|
|
713
|
+
await aclose(inner)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# --------------------------------------------------------------------------- #
|
|
717
|
+
# zip / merge / concat
|
|
718
|
+
# --------------------------------------------------------------------------- #
|
|
719
|
+
def op_zip(
|
|
720
|
+
upstream: Stream[T],
|
|
721
|
+
*others: Any,
|
|
722
|
+
name: str | None = None,
|
|
723
|
+
) -> Stream[tuple[Any, ...]]:
|
|
724
|
+
if not others:
|
|
725
|
+
raise ConfigurationError("zip() requires at least one other stream")
|
|
726
|
+
sources = [_as_stream(other, upstream.config) for other in others]
|
|
727
|
+
replayable = upstream.replayable and all(source.replayable for source in sources)
|
|
728
|
+
all_sources = [upstream, *sources]
|
|
729
|
+
|
|
730
|
+
def factory() -> AsyncIterator[tuple[Any, ...]]:
|
|
731
|
+
return _zip_gen(all_sources)
|
|
732
|
+
|
|
733
|
+
return upstream._derive(factory, name=name, replayable=replayable)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
async def _zip_gen(sources: Iterable[Stream[Any]]) -> AsyncIterator[tuple[Any, ...]]:
|
|
737
|
+
iterators: list[AsyncIterator[Any]] = []
|
|
738
|
+
try:
|
|
739
|
+
for source in sources:
|
|
740
|
+
iterators.append(source._open_raw())
|
|
741
|
+
while True:
|
|
742
|
+
results = await asyncio.gather(
|
|
743
|
+
*(iterator.__anext__() for iterator in iterators),
|
|
744
|
+
return_exceptions=True,
|
|
745
|
+
)
|
|
746
|
+
items: list[Any] = []
|
|
747
|
+
exhausted = False
|
|
748
|
+
for result in results:
|
|
749
|
+
if isinstance(result, StopAsyncIteration):
|
|
750
|
+
exhausted = True
|
|
751
|
+
elif isinstance(result, BaseException):
|
|
752
|
+
raise result
|
|
753
|
+
else:
|
|
754
|
+
items.append(result)
|
|
755
|
+
if exhausted:
|
|
756
|
+
return
|
|
757
|
+
yield tuple(items)
|
|
758
|
+
finally:
|
|
759
|
+
for iterator in iterators:
|
|
760
|
+
await aclose(iterator)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def op_merge(
|
|
764
|
+
upstream: Stream[T],
|
|
765
|
+
*others: Any,
|
|
766
|
+
buffer_size: int | None = None,
|
|
767
|
+
overflow: OverflowPolicy | str | None = None,
|
|
768
|
+
name: str | None = None,
|
|
769
|
+
) -> Stream[T]:
|
|
770
|
+
"""Interleave several streams concurrently, yielding items as they arrive."""
|
|
771
|
+
if not others:
|
|
772
|
+
raise ConfigurationError("merge() requires at least one other stream")
|
|
773
|
+
sources = [upstream, *(_as_stream(other, upstream.config) for other in others)]
|
|
774
|
+
policy = BufferPolicy.from_config(upstream.config, size=buffer_size, overflow=overflow)
|
|
775
|
+
replayable = all(source.replayable for source in sources)
|
|
776
|
+
|
|
777
|
+
def factory() -> AsyncIterator[T]:
|
|
778
|
+
return _merge_gen(sources, policy)
|
|
779
|
+
|
|
780
|
+
return upstream._derive(factory, name=name, replayable=replayable)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
async def _merge_gen(sources: Iterable[Stream[Any]], policy: BufferPolicy) -> AsyncIterator[Any]:
|
|
784
|
+
queue = policy.build(name="merge")
|
|
785
|
+
remaining = len(list(sources)) if not isinstance(sources, list) else len(sources)
|
|
786
|
+
counter = [remaining]
|
|
787
|
+
|
|
788
|
+
async def pump(source: Stream[Any]) -> None:
|
|
789
|
+
iterator = source._open_raw()
|
|
790
|
+
try:
|
|
791
|
+
async for item in iterator:
|
|
792
|
+
try:
|
|
793
|
+
await queue.put(item)
|
|
794
|
+
except QueueClosedError:
|
|
795
|
+
return
|
|
796
|
+
except asyncio.CancelledError:
|
|
797
|
+
raise
|
|
798
|
+
except Exception as error:
|
|
799
|
+
await queue.fail(error)
|
|
800
|
+
finally:
|
|
801
|
+
await aclose(iterator)
|
|
802
|
+
counter[0] -= 1
|
|
803
|
+
if counter[0] == 0:
|
|
804
|
+
await queue.aclose()
|
|
805
|
+
|
|
806
|
+
tasks = [
|
|
807
|
+
asyncio.create_task(pump(source), name=f"datacurrent-merge-{index}")
|
|
808
|
+
for index, source in enumerate(sources)
|
|
809
|
+
]
|
|
810
|
+
try:
|
|
811
|
+
async for item in queue:
|
|
812
|
+
yield item
|
|
813
|
+
finally:
|
|
814
|
+
for task in tasks:
|
|
815
|
+
task.cancel()
|
|
816
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
817
|
+
await queue.aclose()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def op_concat(
|
|
821
|
+
upstream: Stream[T],
|
|
822
|
+
*others: Any,
|
|
823
|
+
name: str | None = None,
|
|
824
|
+
) -> Stream[T]:
|
|
825
|
+
"""Consume streams one after another, preserving order."""
|
|
826
|
+
if not others:
|
|
827
|
+
raise ConfigurationError("concat() requires at least one other stream")
|
|
828
|
+
sources = [upstream, *(_as_stream(other, upstream.config) for other in others)]
|
|
829
|
+
replayable = all(source.replayable for source in sources)
|
|
830
|
+
|
|
831
|
+
def factory() -> AsyncIterator[T]:
|
|
832
|
+
return _concat_gen(sources)
|
|
833
|
+
|
|
834
|
+
return upstream._derive(factory, name=name, replayable=replayable)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
async def _concat_gen(sources: Iterable[Stream[Any]]) -> AsyncIterator[Any]:
|
|
838
|
+
for source in sources:
|
|
839
|
+
iterator = source._open_raw()
|
|
840
|
+
try:
|
|
841
|
+
async for item in iterator:
|
|
842
|
+
yield item
|
|
843
|
+
finally:
|
|
844
|
+
await aclose(iterator)
|