pystreamlet 0.1.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.
- pystreamlet-0.1.0.dist-info/METADATA +387 -0
- pystreamlet-0.1.0.dist-info/RECORD +10 -0
- pystreamlet-0.1.0.dist-info/WHEEL +4 -0
- pystreamlet-0.1.0.dist-info/entry_points.txt +3 -0
- pystreamlet-0.1.0.dist-info/licenses/LICENSE +21 -0
- streamlet/__init__.py +30 -0
- streamlet/async_stream.py +514 -0
- streamlet/file_stream.py +177 -0
- streamlet/py.typed +0 -0
- streamlet/stream.py +446 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Lazy asynchronous streams.
|
|
2
|
+
|
|
3
|
+
``AsyncStream`` mirrors :class:`~streamlet.stream.Stream`, but pulls from an
|
|
4
|
+
async source and is driven with ``async for`` / ``await``. Every operation
|
|
5
|
+
that takes a function accepts either a plain function or a coroutine
|
|
6
|
+
function, so ``map(str)`` and ``map(fetch)`` both work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import builtins
|
|
13
|
+
from collections import defaultdict, deque
|
|
14
|
+
from collections.abc import (
|
|
15
|
+
AsyncIterable,
|
|
16
|
+
AsyncIterator,
|
|
17
|
+
Awaitable,
|
|
18
|
+
Callable,
|
|
19
|
+
Hashable,
|
|
20
|
+
Iterable,
|
|
21
|
+
)
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from typing import Any, Generic, TypeVar, cast, overload
|
|
24
|
+
|
|
25
|
+
from streamlet.stream import StreamConsumedError
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
R = TypeVar("R")
|
|
29
|
+
V = TypeVar("V")
|
|
30
|
+
K = TypeVar("K", bound=Hashable)
|
|
31
|
+
H = TypeVar("H", bound=Hashable)
|
|
32
|
+
Num = TypeVar("Num", int, float, complex)
|
|
33
|
+
|
|
34
|
+
MaybeAsync = Callable[[T], "R | Awaitable[R]"]
|
|
35
|
+
MaybeAsync2 = Callable[[R, T], "R | Awaitable[R]"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _cancel_all(tasks: Iterable[asyncio.Task[Any]]) -> None:
|
|
39
|
+
"""Cancel every task and wait for the cancellations to settle."""
|
|
40
|
+
remaining = [task for task in tasks if not task.done()]
|
|
41
|
+
for task in remaining:
|
|
42
|
+
task.cancel()
|
|
43
|
+
if remaining:
|
|
44
|
+
await asyncio.gather(*remaining, return_exceptions=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _aclose(iterator: AsyncIterator[Any]) -> None:
|
|
48
|
+
"""Close ``iterator`` if it can be closed.
|
|
49
|
+
|
|
50
|
+
Async generators can be finalised on demand; a hand-written
|
|
51
|
+
``AsyncIterator`` has nothing to close, and that is not an error.
|
|
52
|
+
"""
|
|
53
|
+
aclose = getattr(iterator, "aclose", None)
|
|
54
|
+
if aclose is not None:
|
|
55
|
+
await aclose()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def _consuming(source: AsyncIterable[T]) -> AsyncIterator[AsyncIterator[T]]:
|
|
60
|
+
"""Iterate ``source`` and finalise it on the way out, however that happens.
|
|
61
|
+
|
|
62
|
+
``async for`` alone does not close what it iterates, so a stage that is
|
|
63
|
+
abandoned -- a ``take`` that has had enough, a terminal op that
|
|
64
|
+
short-circuits, an exception -- would leave everything upstream of it
|
|
65
|
+
suspended until the event loop shut its async generators down. Closing
|
|
66
|
+
here turns that into a deterministic hand-off: each stage closes the one
|
|
67
|
+
behind it, so the whole chain unwinds to the source.
|
|
68
|
+
|
|
69
|
+
The exception is a bare ``break`` out of an ``async for`` over the
|
|
70
|
+
outermost stage: Python gives nothing to hook there, so that one needs
|
|
71
|
+
``contextlib.aclosing`` at the call site.
|
|
72
|
+
"""
|
|
73
|
+
iterator = source.__aiter__()
|
|
74
|
+
try:
|
|
75
|
+
yield iterator
|
|
76
|
+
finally:
|
|
77
|
+
await _aclose(iterator)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _resolve(value: R | Awaitable[R]) -> R:
|
|
81
|
+
"""Await ``value`` if it is awaitable, otherwise return it unchanged."""
|
|
82
|
+
if isinstance(value, Awaitable):
|
|
83
|
+
return await cast("Awaitable[R]", value)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AsyncStream(Generic[T]):
|
|
88
|
+
"""A lazy, single-use stream over an async source.
|
|
89
|
+
|
|
90
|
+
Nothing runs until a terminal op is awaited::
|
|
91
|
+
|
|
92
|
+
total = await AsyncStream.from_iterable(urls).map(fetch).count()
|
|
93
|
+
|
|
94
|
+
Like :class:`~streamlet.stream.Stream`, a stream is consumed once;
|
|
95
|
+
iterating a second time raises :class:`StreamConsumedError`.
|
|
96
|
+
|
|
97
|
+
Cleanup runs eagerly: each stage closes the one behind it, so a bounded
|
|
98
|
+
``take``, a short-circuiting terminal op or an exception unwinds the
|
|
99
|
+
whole chain and finalises the source there and then. The one case that
|
|
100
|
+
needs help is a bare ``break`` out of an ``async for`` -- wrap the
|
|
101
|
+
iterator in :func:`contextlib.aclosing` when the source holds something
|
|
102
|
+
that must be released promptly.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
__slots__ = ("__weakref__", "_consumed", "_iterator")
|
|
106
|
+
|
|
107
|
+
def __init__(self, source: AsyncIterable[T]) -> None:
|
|
108
|
+
"""Wrap an async source, without pulling anything from it yet."""
|
|
109
|
+
self._iterator: AsyncIterator[T] = source.__aiter__()
|
|
110
|
+
self._consumed = False
|
|
111
|
+
|
|
112
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
113
|
+
"""Return the underlying async iterator, marking the stream consumed.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
StreamConsumedError: if the stream has already been iterated.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
if self._consumed:
|
|
120
|
+
raise StreamConsumedError("this stream has already been consumed")
|
|
121
|
+
self._consumed = True
|
|
122
|
+
return self._iterator
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
"""Show the class and whether the stream still has items to give."""
|
|
126
|
+
state = "consumed" if self._consumed else "lazy"
|
|
127
|
+
return f"<{type(self).__name__} {state}>"
|
|
128
|
+
|
|
129
|
+
# --- constructors ---------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def of(cls, *items: T) -> AsyncStream[T]:
|
|
133
|
+
"""Build a stream from individual arguments."""
|
|
134
|
+
return cls.from_iterable(items)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_iterable(cls, iterable: Iterable[T]) -> AsyncStream[T]:
|
|
138
|
+
"""Build a stream from a plain synchronous iterable."""
|
|
139
|
+
|
|
140
|
+
async def generate() -> AsyncIterator[T]:
|
|
141
|
+
for item in iterable:
|
|
142
|
+
yield item
|
|
143
|
+
|
|
144
|
+
return cls(generate())
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_async_iterable(cls, source: AsyncIterable[T]) -> AsyncStream[T]:
|
|
148
|
+
"""Build a stream from an existing async iterable."""
|
|
149
|
+
return cls(source)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def empty(cls) -> AsyncStream[T]:
|
|
153
|
+
"""Build a stream with no items."""
|
|
154
|
+
return cls.from_iterable(())
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def iterate(cls, seed: T, fn: MaybeAsync[T, T]) -> AsyncStream[T]:
|
|
158
|
+
"""Infinite stream: ``seed``, ``fn(seed)``, ``fn(fn(seed))``, ..."""
|
|
159
|
+
|
|
160
|
+
async def generate() -> AsyncIterator[T]:
|
|
161
|
+
current = seed
|
|
162
|
+
while True:
|
|
163
|
+
yield current
|
|
164
|
+
current = await _resolve(fn(current))
|
|
165
|
+
|
|
166
|
+
return cls(generate())
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def generate(cls, fn: Callable[[], T | Awaitable[T]]) -> AsyncStream[T]:
|
|
170
|
+
"""Infinite stream produced by repeated calls to ``fn``."""
|
|
171
|
+
|
|
172
|
+
async def produce() -> AsyncIterator[T]:
|
|
173
|
+
while True:
|
|
174
|
+
yield await _resolve(fn())
|
|
175
|
+
|
|
176
|
+
return cls(produce())
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def concat(cls, *sources: Iterable[T] | AsyncIterable[T]) -> AsyncStream[T]:
|
|
180
|
+
"""Join sources end to end, lazily."""
|
|
181
|
+
|
|
182
|
+
async def generate() -> AsyncIterator[T]:
|
|
183
|
+
for source in sources:
|
|
184
|
+
if isinstance(source, AsyncIterable):
|
|
185
|
+
async for item in source:
|
|
186
|
+
yield item
|
|
187
|
+
else:
|
|
188
|
+
for item in source:
|
|
189
|
+
yield item
|
|
190
|
+
|
|
191
|
+
return cls(generate())
|
|
192
|
+
|
|
193
|
+
# --- intermediate ops -----------------------------------------------
|
|
194
|
+
|
|
195
|
+
def __or__(self, fn: Callable[[AsyncStream[T]], R]) -> R:
|
|
196
|
+
"""Pipe this stream into ``fn`` -- ``stream | fn`` means ``fn(stream)``."""
|
|
197
|
+
return fn(self)
|
|
198
|
+
|
|
199
|
+
@overload
|
|
200
|
+
def map(self, mapper: Callable[[T], Awaitable[R]]) -> AsyncStream[R]: ...
|
|
201
|
+
|
|
202
|
+
@overload
|
|
203
|
+
def map(self, mapper: Callable[[T], R]) -> AsyncStream[R]: ...
|
|
204
|
+
|
|
205
|
+
def map(self, mapper: MaybeAsync[T, R]) -> AsyncStream[R]:
|
|
206
|
+
"""Apply ``mapper`` to every item, one at a time::
|
|
207
|
+
|
|
208
|
+
await AsyncStream.of("a", "b").map(fetch).to_list()
|
|
209
|
+
|
|
210
|
+
``mapper`` may be a plain function or a coroutine function; an
|
|
211
|
+
awaitable result is awaited before it is yielded.
|
|
212
|
+
|
|
213
|
+
See :meth:`map_concurrent` to run an async mapper over several items
|
|
214
|
+
at once.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
async def generate() -> AsyncIterator[R]:
|
|
218
|
+
async with _consuming(self) as items:
|
|
219
|
+
async for item in items:
|
|
220
|
+
yield await _resolve(mapper(item))
|
|
221
|
+
|
|
222
|
+
return AsyncStream(generate())
|
|
223
|
+
|
|
224
|
+
def map_concurrent(
|
|
225
|
+
self,
|
|
226
|
+
mapper: Callable[[T], Awaitable[R]],
|
|
227
|
+
*,
|
|
228
|
+
limit: int = 8,
|
|
229
|
+
ordered: bool = True,
|
|
230
|
+
) -> AsyncStream[R]:
|
|
231
|
+
"""Apply an async ``mapper`` to several items at once.
|
|
232
|
+
|
|
233
|
+
At most ``limit`` calls are in flight at any moment, and items are
|
|
234
|
+
only pulled from the source as slots free up -- so this stays lazy and
|
|
235
|
+
works on infinite sources.
|
|
236
|
+
|
|
237
|
+
With ``ordered=True`` (the default) results come out in source order,
|
|
238
|
+
which costs some latency: a slow item holds back the ones behind it.
|
|
239
|
+
With ``ordered=False`` each result is yielded as soon as it is ready.
|
|
240
|
+
|
|
241
|
+
If the mapper raises, the exception propagates as-is and every
|
|
242
|
+
in-flight call is cancelled. Abandoning the stream early -- a
|
|
243
|
+
``take``, a ``break`` -- cancels them too.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
mapper: an async callable applied to each item.
|
|
247
|
+
limit: maximum number of concurrent calls. Must be at least 1.
|
|
248
|
+
ordered: whether to preserve source order.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ValueError: if ``limit`` is less than 1.
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
if limit < 1:
|
|
255
|
+
raise ValueError(f"map_concurrent() requires a limit of at least 1, got {limit}")
|
|
256
|
+
|
|
257
|
+
generate = self._map_ordered if ordered else self._map_unordered
|
|
258
|
+
return AsyncStream(generate(mapper, limit))
|
|
259
|
+
|
|
260
|
+
async def _map_ordered(
|
|
261
|
+
self, mapper: Callable[[T], Awaitable[R]], limit: int
|
|
262
|
+
) -> AsyncIterator[R]:
|
|
263
|
+
"""Sliding window of in-flight tasks, yielded in source order."""
|
|
264
|
+
window: deque[asyncio.Task[R]] = deque()
|
|
265
|
+
try:
|
|
266
|
+
async with _consuming(self) as items:
|
|
267
|
+
async for item in items:
|
|
268
|
+
window.append(asyncio.ensure_future(mapper(item)))
|
|
269
|
+
if len(window) >= limit:
|
|
270
|
+
yield await window.popleft()
|
|
271
|
+
while window:
|
|
272
|
+
yield await window.popleft()
|
|
273
|
+
finally:
|
|
274
|
+
await _cancel_all(window)
|
|
275
|
+
|
|
276
|
+
async def _map_unordered(
|
|
277
|
+
self, mapper: Callable[[T], Awaitable[R]], limit: int
|
|
278
|
+
) -> AsyncIterator[R]:
|
|
279
|
+
"""Same window, but each result is yielded as soon as it is ready."""
|
|
280
|
+
pending: set[asyncio.Task[R]] = set()
|
|
281
|
+
source_done = False
|
|
282
|
+
iterator = self.__aiter__()
|
|
283
|
+
try:
|
|
284
|
+
while True:
|
|
285
|
+
while not source_done and len(pending) < limit:
|
|
286
|
+
try:
|
|
287
|
+
item = await iterator.__anext__()
|
|
288
|
+
except StopAsyncIteration:
|
|
289
|
+
source_done = True
|
|
290
|
+
else:
|
|
291
|
+
pending.add(asyncio.ensure_future(mapper(item)))
|
|
292
|
+
if not pending:
|
|
293
|
+
return
|
|
294
|
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
295
|
+
for task in done:
|
|
296
|
+
yield task.result()
|
|
297
|
+
finally:
|
|
298
|
+
await _cancel_all(pending)
|
|
299
|
+
await _aclose(iterator)
|
|
300
|
+
|
|
301
|
+
def filter(self, predicate: MaybeAsync[T, bool]) -> AsyncStream[T]:
|
|
302
|
+
"""Keep only items where ``predicate`` is true."""
|
|
303
|
+
|
|
304
|
+
async def generate() -> AsyncIterator[T]:
|
|
305
|
+
async with _consuming(self) as items:
|
|
306
|
+
async for item in items:
|
|
307
|
+
if await _resolve(predicate(item)):
|
|
308
|
+
yield item
|
|
309
|
+
|
|
310
|
+
return AsyncStream(generate())
|
|
311
|
+
|
|
312
|
+
def flat_map(self, fn: MaybeAsync[T, Iterable[R]]) -> AsyncStream[R]:
|
|
313
|
+
"""Map each item to an iterable and flatten one level."""
|
|
314
|
+
|
|
315
|
+
async def generate() -> AsyncIterator[R]:
|
|
316
|
+
async with _consuming(self) as items:
|
|
317
|
+
async for item in items:
|
|
318
|
+
for result in await _resolve(fn(item)):
|
|
319
|
+
yield result
|
|
320
|
+
|
|
321
|
+
return AsyncStream(generate())
|
|
322
|
+
|
|
323
|
+
def distinct(self) -> AsyncStream[T]:
|
|
324
|
+
"""Yield items the first time they are seen, preserving order."""
|
|
325
|
+
|
|
326
|
+
async def generate() -> AsyncIterator[T]:
|
|
327
|
+
seen: set[T] = set()
|
|
328
|
+
async with _consuming(self) as items:
|
|
329
|
+
async for item in items:
|
|
330
|
+
if item not in seen:
|
|
331
|
+
seen.add(item)
|
|
332
|
+
yield item
|
|
333
|
+
|
|
334
|
+
return AsyncStream(generate())
|
|
335
|
+
|
|
336
|
+
def take(self, n: int) -> AsyncStream[T]:
|
|
337
|
+
"""Yield at most the first ``n`` items.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
ValueError: if ``n`` is negative.
|
|
341
|
+
|
|
342
|
+
"""
|
|
343
|
+
if n < 0:
|
|
344
|
+
raise ValueError(f"take() requires a non-negative count, got {n}")
|
|
345
|
+
|
|
346
|
+
async def generate() -> AsyncIterator[T]:
|
|
347
|
+
if n == 0:
|
|
348
|
+
return
|
|
349
|
+
taken = 0
|
|
350
|
+
async with _consuming(self) as items:
|
|
351
|
+
async for item in items:
|
|
352
|
+
yield item
|
|
353
|
+
taken += 1
|
|
354
|
+
if taken >= n:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
return AsyncStream(generate())
|
|
358
|
+
|
|
359
|
+
def skip(self, n: int) -> AsyncStream[T]:
|
|
360
|
+
"""Discard the first ``n`` items.
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
ValueError: if ``n`` is negative.
|
|
364
|
+
|
|
365
|
+
"""
|
|
366
|
+
if n < 0:
|
|
367
|
+
raise ValueError(f"skip() requires a non-negative count, got {n}")
|
|
368
|
+
|
|
369
|
+
async def generate() -> AsyncIterator[T]:
|
|
370
|
+
skipped = 0
|
|
371
|
+
async with _consuming(self) as items:
|
|
372
|
+
async for item in items:
|
|
373
|
+
if skipped < n:
|
|
374
|
+
skipped += 1
|
|
375
|
+
continue
|
|
376
|
+
yield item
|
|
377
|
+
|
|
378
|
+
return AsyncStream(generate())
|
|
379
|
+
|
|
380
|
+
def take_while(self, predicate: MaybeAsync[T, bool]) -> AsyncStream[T]:
|
|
381
|
+
"""Yield items until ``predicate`` first fails, then stop."""
|
|
382
|
+
|
|
383
|
+
async def generate() -> AsyncIterator[T]:
|
|
384
|
+
async with _consuming(self) as items:
|
|
385
|
+
async for item in items:
|
|
386
|
+
if not await _resolve(predicate(item)):
|
|
387
|
+
return
|
|
388
|
+
yield item
|
|
389
|
+
|
|
390
|
+
return AsyncStream(generate())
|
|
391
|
+
|
|
392
|
+
def drop_while(self, predicate: MaybeAsync[T, bool]) -> AsyncStream[T]:
|
|
393
|
+
"""Discard items until ``predicate`` first fails, then yield the rest."""
|
|
394
|
+
|
|
395
|
+
async def generate() -> AsyncIterator[T]:
|
|
396
|
+
dropping = True
|
|
397
|
+
async with _consuming(self) as items:
|
|
398
|
+
async for item in items:
|
|
399
|
+
if dropping and await _resolve(predicate(item)):
|
|
400
|
+
continue
|
|
401
|
+
dropping = False
|
|
402
|
+
yield item
|
|
403
|
+
|
|
404
|
+
return AsyncStream(generate())
|
|
405
|
+
|
|
406
|
+
def peek(self, action: MaybeAsync[T, object]) -> AsyncStream[T]:
|
|
407
|
+
"""Run ``action`` on each item as it passes, yielding it unchanged."""
|
|
408
|
+
|
|
409
|
+
async def generate() -> AsyncIterator[T]:
|
|
410
|
+
async with _consuming(self) as items:
|
|
411
|
+
async for item in items:
|
|
412
|
+
await _resolve(action(item))
|
|
413
|
+
yield item
|
|
414
|
+
|
|
415
|
+
return AsyncStream(generate())
|
|
416
|
+
|
|
417
|
+
# --- terminal ops ---------------------------------------------------
|
|
418
|
+
|
|
419
|
+
async def to_list(self) -> list[T]:
|
|
420
|
+
"""Materialise the stream into a list."""
|
|
421
|
+
return [item async for item in self]
|
|
422
|
+
|
|
423
|
+
async def to_tuple(self) -> tuple[T, ...]:
|
|
424
|
+
"""Materialise the stream into a tuple."""
|
|
425
|
+
return tuple(await self.to_list())
|
|
426
|
+
|
|
427
|
+
async def to_set(self: AsyncStream[H]) -> set[H]:
|
|
428
|
+
"""Collect the items into a set. Only for hashable items."""
|
|
429
|
+
return {item async for item in self}
|
|
430
|
+
|
|
431
|
+
@overload
|
|
432
|
+
async def to_dict(self, key: Callable[[T], K]) -> dict[K, T]: ...
|
|
433
|
+
|
|
434
|
+
@overload
|
|
435
|
+
async def to_dict(self, key: Callable[[T], K], value: Callable[[T], V]) -> dict[K, V]: ...
|
|
436
|
+
|
|
437
|
+
async def to_dict(
|
|
438
|
+
self,
|
|
439
|
+
key: Callable[[T], K],
|
|
440
|
+
value: Callable[[T], V] | None = None,
|
|
441
|
+
) -> dict[K, T] | dict[K, V]:
|
|
442
|
+
"""Collect into a dict keyed by ``key``. Later items win."""
|
|
443
|
+
if value is None:
|
|
444
|
+
return {key(item): item async for item in self}
|
|
445
|
+
return {key(item): value(item) async for item in self}
|
|
446
|
+
|
|
447
|
+
async def join(self: AsyncStream[str], separator: str = "") -> str:
|
|
448
|
+
"""Concatenate the items with ``separator``. Only for string streams."""
|
|
449
|
+
return separator.join(await self.to_list())
|
|
450
|
+
|
|
451
|
+
async def group_by(self, key: Callable[[T], K]) -> dict[K, list[T]]:
|
|
452
|
+
"""Group items by ``key``, consuming the stream."""
|
|
453
|
+
groups: defaultdict[K, list[T]] = defaultdict(list)
|
|
454
|
+
async for item in self:
|
|
455
|
+
groups[key(item)].append(item)
|
|
456
|
+
return dict(groups)
|
|
457
|
+
|
|
458
|
+
async def count(self) -> int:
|
|
459
|
+
"""Count the items, consuming the stream."""
|
|
460
|
+
total = 0
|
|
461
|
+
async for _ in self:
|
|
462
|
+
total += 1
|
|
463
|
+
return total
|
|
464
|
+
|
|
465
|
+
async def sum(self: AsyncStream[Num]) -> Num:
|
|
466
|
+
"""Add the items together. Only for numeric streams."""
|
|
467
|
+
return builtins.sum(await self.to_list())
|
|
468
|
+
|
|
469
|
+
async def min(self, key: Callable[[T], Any] | None = None) -> T | None:
|
|
470
|
+
"""Return the smallest item, or ``None`` if the stream is empty."""
|
|
471
|
+
return builtins.min(await self.to_list(), key=key, default=None) # type: ignore[type-var,arg-type]
|
|
472
|
+
|
|
473
|
+
async def max(self, key: Callable[[T], Any] | None = None) -> T | None:
|
|
474
|
+
"""Return the largest item, or ``None`` if the stream is empty."""
|
|
475
|
+
return builtins.max(await self.to_list(), key=key, default=None) # type: ignore[type-var,arg-type]
|
|
476
|
+
|
|
477
|
+
async def reduce(self, fn: MaybeAsync2[R, T], initial: R) -> R:
|
|
478
|
+
"""Fold the stream into a single value, starting from ``initial``."""
|
|
479
|
+
accumulator = initial
|
|
480
|
+
async for item in self:
|
|
481
|
+
accumulator = await _resolve(fn(accumulator, item))
|
|
482
|
+
return accumulator
|
|
483
|
+
|
|
484
|
+
async def first(self) -> T | None:
|
|
485
|
+
"""Return the first item, or ``None`` if the stream is empty."""
|
|
486
|
+
async with _consuming(self) as items:
|
|
487
|
+
async for item in items:
|
|
488
|
+
return item
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
async def any(self, predicate: MaybeAsync[T, bool]) -> bool:
|
|
492
|
+
"""True if any item matches. Stops at the first match."""
|
|
493
|
+
async with _consuming(self) as items:
|
|
494
|
+
async for item in items:
|
|
495
|
+
if await _resolve(predicate(item)):
|
|
496
|
+
return True
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
async def all(self, predicate: MaybeAsync[T, bool]) -> bool:
|
|
500
|
+
"""True if every item matches. Stops at the first failure."""
|
|
501
|
+
async with _consuming(self) as items:
|
|
502
|
+
async for item in items:
|
|
503
|
+
if not await _resolve(predicate(item)):
|
|
504
|
+
return False
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
async def none(self, predicate: MaybeAsync[T, bool]) -> bool:
|
|
508
|
+
"""True if no item matches. Stops at the first match."""
|
|
509
|
+
return not await self.any(predicate)
|
|
510
|
+
|
|
511
|
+
async def for_each(self, action: MaybeAsync[T, object]) -> None:
|
|
512
|
+
"""Run ``action`` on every item, consuming the stream."""
|
|
513
|
+
async for item in self:
|
|
514
|
+
await _resolve(action(item))
|
streamlet/file_stream.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Context-managed streams over resources that need closing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import IO, Protocol, Self, TypeVar, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from streamlet.stream import Stream
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class ClosableIterable(Protocol[T_co]):
|
|
19
|
+
"""Anything iterable that also needs closing.
|
|
20
|
+
|
|
21
|
+
Files satisfy it, and so do sockets opened with ``makefile()``,
|
|
22
|
+
``os.popen()`` pipes, ``io.StringIO`` buffers, and database cursors.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __iter__(self) -> Iterator[T_co]: ...
|
|
26
|
+
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
"""Release the resource."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NoClose(Iterator[T]):
|
|
33
|
+
"""An iterator with no ``close()``, wrapped around one that has it.
|
|
34
|
+
|
|
35
|
+
``yield from`` closes whatever it delegates to, which is exactly wrong
|
|
36
|
+
for a resource the stream does not own. Hiding ``close()`` behind this
|
|
37
|
+
wrapper keeps an unowned resource open.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("_iterator",)
|
|
41
|
+
|
|
42
|
+
def __init__(self, iterable: Iterable[T]) -> None:
|
|
43
|
+
self._iterator = iter(iterable)
|
|
44
|
+
|
|
45
|
+
def __iter__(self) -> Iterator[T]:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __next__(self) -> T:
|
|
49
|
+
return next(self._iterator)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ResourceStream(Stream[T]):
|
|
53
|
+
"""A lazy stream over a resource, with explicit ownership.
|
|
54
|
+
|
|
55
|
+
With ``close=True`` the stream owns the resource and closes it on every
|
|
56
|
+
exit path: exhausting the stream, stopping early with a ``break`` or a
|
|
57
|
+
partial read, an exception escaping iteration, or leaving a ``with``
|
|
58
|
+
block. With ``close=False`` the resource is never closed for you --
|
|
59
|
+
whoever opened it stays responsible for it.
|
|
60
|
+
|
|
61
|
+
Reach for this through :meth:`Stream.from_handle`; use
|
|
62
|
+
:meth:`Stream.from_file` when you have a path rather than a handle.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("__weakref__", "_owned", "_resource")
|
|
66
|
+
|
|
67
|
+
def __init__(self, resource: ClosableIterable[T], *, close: bool = True) -> None:
|
|
68
|
+
self._resource = resource
|
|
69
|
+
self._owned = close
|
|
70
|
+
super().__init__(self._drain(resource) if close else _NoClose(resource))
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _drain(resource: ClosableIterable[T]) -> Iterator[T]:
|
|
74
|
+
try:
|
|
75
|
+
yield from resource
|
|
76
|
+
finally:
|
|
77
|
+
resource.close()
|
|
78
|
+
|
|
79
|
+
def __iter__(self) -> Iterator[T]:
|
|
80
|
+
"""Iterate the items, closing an owned resource if anything goes wrong.
|
|
81
|
+
|
|
82
|
+
The generator's own ``finally`` covers exhaustion, but an exception
|
|
83
|
+
raised in the *caller's* loop body only suspends the generator -- it
|
|
84
|
+
does not finalise it. Closing here covers that case too.
|
|
85
|
+
"""
|
|
86
|
+
iterator = super().__iter__()
|
|
87
|
+
try:
|
|
88
|
+
yield from iterator
|
|
89
|
+
except BaseException:
|
|
90
|
+
if self._owned:
|
|
91
|
+
self.close()
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> Self:
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(
|
|
98
|
+
self,
|
|
99
|
+
exc_type: type[BaseException] | None,
|
|
100
|
+
exc: BaseException | None,
|
|
101
|
+
traceback: TracebackType | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
if self._owned:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def owned(self) -> bool:
|
|
108
|
+
"""Whether this stream is responsible for closing the resource."""
|
|
109
|
+
return self._owned
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Close the underlying resource. Safe to call more than once.
|
|
113
|
+
|
|
114
|
+
Explicit, so it closes even a resource the stream does not own.
|
|
115
|
+
"""
|
|
116
|
+
self._resource.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FileStream(ResourceStream[str]):
|
|
120
|
+
"""A lazy stream of lines that owns its file handle.
|
|
121
|
+
|
|
122
|
+
The handle is closed on every exit path: exhausting the stream, stopping
|
|
123
|
+
early with a ``break`` or a partial read, an exception escaping iteration,
|
|
124
|
+
or leaving a ``with`` block.
|
|
125
|
+
|
|
126
|
+
``with`` is still the clearest way to express the intent, and it closes
|
|
127
|
+
the file even if you never iterate at all::
|
|
128
|
+
|
|
129
|
+
with Stream.from_file("app.log") as lines:
|
|
130
|
+
first_error = lines.filter(lambda line: "ERROR" in line).first()
|
|
131
|
+
|
|
132
|
+
Lines keep their trailing newline, matching ``open()``. Use
|
|
133
|
+
``.map(str.rstrip)`` if you want them stripped.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
__slots__ = ("_handle",)
|
|
137
|
+
|
|
138
|
+
def __init__(self, handle: IO[str]) -> None:
|
|
139
|
+
self._handle = handle
|
|
140
|
+
super().__init__(handle)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def closed(self) -> bool:
|
|
144
|
+
"""Whether the underlying file has been closed."""
|
|
145
|
+
return self._handle.closed
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def open_file_stream(
|
|
149
|
+
path: str | PathLike[str],
|
|
150
|
+
*,
|
|
151
|
+
encoding: str = "utf-8",
|
|
152
|
+
errors: str | None = None,
|
|
153
|
+
newline: str | None = None,
|
|
154
|
+
) -> FileStream:
|
|
155
|
+
"""Open ``path`` for reading and return a :class:`FileStream` of its lines."""
|
|
156
|
+
# SIM115: the returned FileStream owns this handle and closes it on every
|
|
157
|
+
# exit path -- exhaustion, exception, or leaving its ``with`` block.
|
|
158
|
+
handle = Path(path).open(encoding=encoding, errors=errors, newline=newline) # noqa: SIM115
|
|
159
|
+
return FileStream(handle)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resource_stream(
|
|
163
|
+
resource: ClosableIterable[T] | Iterable[T],
|
|
164
|
+
*,
|
|
165
|
+
close: bool = True,
|
|
166
|
+
) -> ResourceStream[T]:
|
|
167
|
+
"""Wrap ``resource`` in a :class:`ResourceStream`.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
TypeError: if the resource has no ``close()`` method.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(resource, ClosableIterable):
|
|
174
|
+
raise TypeError(
|
|
175
|
+
f"from_handle() needs an iterable with a close() method, got {type(resource).__name__}"
|
|
176
|
+
)
|
|
177
|
+
return ResourceStream(resource, close=close)
|
streamlet/py.typed
ADDED
|
File without changes
|