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,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)
@@ -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__")