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.
@@ -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))
@@ -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