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.
Files changed (43) hide show
  1. datacurrent/__init__.py +157 -0
  2. datacurrent/_version.py +6 -0
  3. datacurrent/buffering/__init__.py +8 -0
  4. datacurrent/buffering/backpressure.py +47 -0
  5. datacurrent/buffering/queue.py +304 -0
  6. datacurrent/concurrency/__init__.py +8 -0
  7. datacurrent/concurrency/limits.py +106 -0
  8. datacurrent/concurrency/tasks.py +86 -0
  9. datacurrent/config.py +86 -0
  10. datacurrent/core/__init__.py +17 -0
  11. datacurrent/core/_util.py +42 -0
  12. datacurrent/core/lifecycle.py +81 -0
  13. datacurrent/core/operators.py +844 -0
  14. datacurrent/core/pipeline.py +64 -0
  15. datacurrent/core/sink.py +70 -0
  16. datacurrent/core/stream.py +566 -0
  17. datacurrent/events/__init__.py +9 -0
  18. datacurrent/events/bus.py +380 -0
  19. datacurrent/events/routing.py +59 -0
  20. datacurrent/events/subscriptions.py +75 -0
  21. datacurrent/exceptions.py +191 -0
  22. datacurrent/integrations/__init__.py +18 -0
  23. datacurrent/integrations/fastapi.py +76 -0
  24. datacurrent/integrations/starlette.py +75 -0
  25. datacurrent/observability/__init__.py +17 -0
  26. datacurrent/observability/logging.py +128 -0
  27. datacurrent/observability/metrics.py +127 -0
  28. datacurrent/py.typed +1 -0
  29. datacurrent/reliability/__init__.py +14 -0
  30. datacurrent/reliability/retry.py +134 -0
  31. datacurrent/reliability/timeout.py +39 -0
  32. datacurrent/transports/__init__.py +31 -0
  33. datacurrent/transports/_deps.py +23 -0
  34. datacurrent/transports/base.py +80 -0
  35. datacurrent/transports/http.py +211 -0
  36. datacurrent/transports/memory.py +171 -0
  37. datacurrent/transports/sse.py +374 -0
  38. datacurrent/transports/websocket.py +277 -0
  39. datacurrent-1.0.0.dist-info/METADATA +250 -0
  40. datacurrent-1.0.0.dist-info/RECORD +43 -0
  41. datacurrent-1.0.0.dist-info/WHEEL +5 -0
  42. datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
  43. 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)