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,387 @@
1
+ Metadata-Version: 2.4
2
+ Name: pystreamlet
3
+ Version: 0.1.0
4
+ Summary: A fluent, lazy stream-processing library for Python, modeled after Java's Streams API
5
+ Keywords: stream,streams,lazy,itertools,functional,pipeline,async
6
+ Author: 0xthreadsafe
7
+ Author-email: 0xthreadsafe <a.ghasabeh@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/0xthreadsafe/streamlet
22
+ Project-URL: Repository, https://github.com/0xthreadsafe/streamlet
23
+ Project-URL: Changelog, https://github.com/0xthreadsafe/streamlet/blob/main/CHANGELOG.md
24
+ Project-URL: Issues, https://github.com/0xthreadsafe/streamlet/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Streamlet
28
+
29
+ A fluent, **lazy** stream-processing library for Python, modeled after Java's Streams API.
30
+
31
+ ```python
32
+ from streamlet import Stream
33
+
34
+ (
35
+ Stream.from_iterable(range(100))
36
+ .filter(lambda n: n % 2 == 0)
37
+ .map(lambda n: n**2)
38
+ .take(5)
39
+ .to_list()
40
+ )
41
+ # [0, 4, 16, 36, 64]
42
+ ```
43
+
44
+ The goal is to make `itertools`' power reachable through clean, chainable syntax.
45
+
46
+ ```bash
47
+ pip install pystreamlet # or: uv add pystreamlet
48
+ ```
49
+
50
+ You install `pystreamlet` but import `streamlet`, as above — PyPI reserves names confusable with
51
+ `streamlit`. Requires **Python 3.11+**, has no dependencies, and ships type information
52
+ (`py.typed`).
53
+
54
+ > **Status: 0.1.0, awaiting its first upload.** Everything below works and is tested; until the
55
+ > release lands on PyPI, install from source (see [Development](#development)). Before 1.0.0 the
56
+ > API may still change — see [versioning](#versioning).
57
+
58
+ ## Why
59
+
60
+ `itertools` is powerful but reads inside out — you write the last step first:
61
+
62
+ ```python
63
+ # itertools: read from the middle outwards
64
+ from itertools import count, islice
65
+
66
+ list(islice((n**2 for n in count() if n % 2 == 0), 5))
67
+
68
+ # streamlet: read left to right, in the order it happens
69
+ (
70
+ Stream.iterate(0, lambda n: n + 1)
71
+ .filter(lambda n: n % 2 == 0)
72
+ .map(lambda n: n**2)
73
+ .take(5)
74
+ .to_list()
75
+ )
76
+ ```
77
+
78
+ Both are lazy and both handle infinite sources. The difference is that one describes the
79
+ pipeline in the order it runs.
80
+
81
+ ### Side by side
82
+
83
+ **Take the first few results of an expensive step.** `islice` wraps what it slices, so the
84
+ bound you care about ends up furthest from the work it bounds:
85
+
86
+ ```python
87
+ from itertools import islice
88
+
89
+ list(islice(map(fetch, filter(is_recent, urls)), 5))
90
+
91
+ Stream(urls).filter(is_recent).map(fetch).take(5).to_list()
92
+ ```
93
+
94
+ **Group items by a key.** `itertools.groupby` only groups *adjacent* items, so it needs a sort
95
+ first and hands back iterators that expire as you advance:
96
+
97
+ ```python
98
+ from itertools import groupby
99
+ from operator import attrgetter
100
+
101
+ by_team = {
102
+ team: list(members)
103
+ for team, members in groupby(sorted(users, key=attrgetter("team")), key=attrgetter("team"))
104
+ }
105
+
106
+ by_team = Stream(users).group_by(attrgetter("team"))
107
+ ```
108
+
109
+ **Drop repeats but keep order.** The `set` version loses order, and the `dict.fromkeys` trick
110
+ reads as a puzzle:
111
+
112
+ ```python
113
+ list(dict.fromkeys(names))
114
+
115
+ Stream(names).distinct().to_list()
116
+ ```
117
+
118
+ **Chain a few steps over a file.** The itertools version needs a `with` block, a generator
119
+ expression and a slice, in three different directions:
120
+
121
+ ```python
122
+ with open("app.log", encoding="utf-8") as handle:
123
+ first_errors = list(islice((line for line in handle if line.startswith("ERROR")), 10))
124
+
125
+ with Stream.from_file("app.log") as lines:
126
+ first_errors = lines.filter(lambda line: line.startswith("ERROR")).take(10).to_list()
127
+ ```
128
+
129
+ **Stop as soon as one item matches.** `next()` with a default over a generator expression says
130
+ the same thing, more quietly:
131
+
132
+ ```python
133
+ next((user for user in users if user.is_admin), None)
134
+
135
+ Stream(users).filter(lambda user: user.is_admin).first()
136
+ ```
137
+
138
+ None of these are faster than the `itertools` version — they compile to the same generators.
139
+ They are shorter to read and, more usefully, they read in the order the work happens.
140
+
141
+ ## Laziness
142
+
143
+ Nothing executes until a terminal op runs. Intermediate ops just build a pipeline:
144
+
145
+ ```python
146
+ stream = Stream.from_iterable(count()).map(expensive) # expensive() has not been called
147
+ stream.take(3).to_list() # now it runs -- exactly 3 times
148
+ ```
149
+
150
+ This is what makes infinite sources safe:
151
+
152
+ ```python
153
+ Stream.iterate(1, lambda n: n * 2).take_while(lambda n: n < 100).to_list()
154
+ # [1, 2, 4, 8, 16, 32, 64]
155
+ ```
156
+
157
+ ## Single use
158
+
159
+ A stream is consumed once, like the iterator underneath it. Reusing one raises rather than
160
+ silently yielding nothing:
161
+
162
+ ```python
163
+ stream = Stream.of(1, 2, 3)
164
+ stream.to_list() # [1, 2, 3]
165
+ stream.to_list() # StreamConsumedError
166
+ ```
167
+
168
+ ## API
169
+
170
+ ### Creating a stream
171
+
172
+ | | |
173
+ |---|---|
174
+ | `Stream(iterable)` | wrap any iterable |
175
+ | `Stream.of(*items)` | from individual arguments |
176
+ | `Stream.from_iterable(iterable)` | from an existing iterable |
177
+ | `Stream.empty()` | no items |
178
+ | `Stream.iterate(seed, fn)` | infinite: `seed`, `fn(seed)`, `fn(fn(seed))`, … |
179
+ | `Stream.generate(fn)` | infinite: repeated calls to `fn()` |
180
+ | `Stream.concat(*iterables)` | join sources end to end |
181
+ | `Stream.from_file(path)` | lines of a file, handle managed |
182
+ | `Stream.from_handle(handle, close=True)` | a handle you already have, ownership stated |
183
+
184
+ ### Intermediate ops — lazy, return a new `Stream`
185
+
186
+ | | |
187
+ |---|---|
188
+ | `.map(fn)` | apply `fn` to every item |
189
+ | `.filter(predicate)` | keep matching items |
190
+ | `.flat_map(fn)` | map to iterables and flatten one level |
191
+ | `.distinct()` | drop repeats, keeping first-seen order |
192
+ | `.take(n)` / `.skip(n)` | first `n` / everything after `n` |
193
+ | `.take_while(p)` / `.drop_while(p)` | stop at, or skip until, the first failure |
194
+ | `.peek(action)` | run a side effect per item, yielding it unchanged |
195
+ | `.sorted(key=None, reverse=False)` | sort — **buffers the whole stream** |
196
+ | `.reverse()` | reverse order — **buffers the whole stream** |
197
+
198
+ ### Terminal ops — consume the stream, return a value
199
+
200
+ | | |
201
+ |---|---|
202
+ | `.to_list()` `.to_tuple()` `.to_set()` | materialise |
203
+ | `.to_dict(key, value=None)` | collect into a dict (last key wins) |
204
+ | `.join(separator="")` | concatenate a `Stream[str]` |
205
+ | `.group_by(key)` | `dict` of key → members |
206
+ | `.reduce(fn, initial)` | fold into a single value |
207
+ | `.sum()` `.count()` `.min(key=None)` `.max(key=None)` | aggregate |
208
+ | `.first()` | first item, or `None` |
209
+ | `.any(p)` `.all(p)` `.none(p)` | matching — all short-circuit |
210
+ | `.for_each(action)` | run an action per item |
211
+
212
+ ### Pipe operator
213
+
214
+ Any function taking a `Stream` can act as a stage, so you can extend the library without
215
+ subclassing:
216
+
217
+ ```python
218
+ def errors_only(stream):
219
+ return stream.filter(lambda line: line.startswith("ERROR"))
220
+
221
+
222
+ Stream(lines) | errors_only | (lambda s: s.take(10)) | list
223
+ ```
224
+
225
+ ## Files
226
+
227
+ `Stream.from_file` streams a file's lines lazily and owns the handle:
228
+
229
+ ```python
230
+ with Stream.from_file("app.log") as lines:
231
+ errors = lines.map(str.rstrip).filter(lambda line: line.startswith("ERROR")).to_list()
232
+ ```
233
+
234
+ The file is closed on every exit path: exhausting the stream, stopping early with a `break` or
235
+ a partial read, an exception escaping iteration, or leaving the `with` block. `with` remains the
236
+ clearest way to express the intent, and it closes the file even if you never iterate at all.
237
+
238
+ Lines keep their trailing newline, matching `open()`. Pass `encoding`, `errors` or `newline`
239
+ through as needed.
240
+
241
+ ### Handles you already have
242
+
243
+ `Stream(handle)` leaves the handle to whoever opened it — the right default, but it leaks if
244
+ nobody follows up. `Stream.from_handle` makes the hand-off explicit:
245
+
246
+ ```python
247
+ Stream.from_handle(sock.makefile()) # the stream closes it
248
+ Stream.from_handle(sys.stdin, close=False) # you keep it
249
+ ```
250
+
251
+ With `close=True` (the default) the resource is closed on the same paths `from_file` covers;
252
+ with `close=False` nothing is closed for you. It works for anything iterable with a `close()` —
253
+ socket files, `os.popen` pipes, `io.StringIO`, database cursors — and raises `TypeError` for an
254
+ iterable that has none. Streamlet never auto-detects handles in `Stream(...)`: silently closing
255
+ something you opened would break the whoever-opens-closes convention.
256
+
257
+ ## Async
258
+
259
+ `AsyncStream[T]` is the same API over an async source, driven with `async for` and `await`:
260
+
261
+ ```python
262
+ import asyncio
263
+
264
+ from streamlet import AsyncStream
265
+
266
+
267
+ async def fetch(url: str) -> str: ...
268
+
269
+
270
+ async def main() -> None:
271
+ titles = await (
272
+ AsyncStream.from_iterable(urls)
273
+ .filter(lambda u: u.startswith("https://"))
274
+ .map(fetch)
275
+ .take(10)
276
+ .to_list()
277
+ )
278
+ ```
279
+
280
+ Every op that takes a function accepts **either a plain function or a coroutine function**, so
281
+ `.map(str)` and `.map(fetch)` both work and both type correctly.
282
+
283
+ ### Concurrency
284
+
285
+ `.map(fetch)` awaits one item at a time. `.map_concurrent(fetch)` runs several at once:
286
+
287
+ ```python
288
+ results = await AsyncStream.from_iterable(urls).map_concurrent(fetch, limit=8).to_list()
289
+ ```
290
+
291
+ At most `limit` calls are in flight, and items are pulled from the source only as slots free up —
292
+ so it stays lazy and works on infinite sources. Results come back in source order by default;
293
+ pass `ordered=False` to get each one as soon as it is ready.
294
+
295
+ If the mapper raises, the original exception propagates (not an `ExceptionGroup`) and every
296
+ in-flight call is cancelled. Abandoning the stream early — a `take`, a `break` — cancels them too.
297
+
298
+ A runnable fan-out demo lives in [`examples/concurrent_fan_out.py`](examples/concurrent_fan_out.py):
299
+
300
+ ```bash
301
+ uv run python examples/concurrent_fan_out.py
302
+ ```
303
+
304
+ ```
305
+ sequential : 24 requests in 1.95s (1 at a time)
306
+ concurrent : 24 requests in 0.24s (up to 8 at once) -- 8.0x faster
307
+ first infra user: user-02 -- fetched 8/24 users, not all
308
+ ```
309
+
310
+ Sources can be sync or async: `AsyncStream.of(...)`, `.from_iterable(list)`,
311
+ `.from_async_iterable(agen)`, `.empty()`, `.iterate(seed, fn)`, `.generate(fn)`, and
312
+ `.concat(...)` — which mixes both kinds.
313
+
314
+ Terminal ops are coroutines, so they need `await`. Everything else matches `Stream`: the same
315
+ intermediate ops, the same single-use semantics, the same laziness.
316
+
317
+ ### Cleanup
318
+
319
+ Each stage closes the one behind it, so a pipeline that stops early finalises its source right
320
+ away — a bounded `take`, a short-circuiting `first`/`any`/`all`, or an exception all unwind the
321
+ whole chain. That matters when the source holds something: a connection, a cursor, a handle.
322
+
323
+ One case Python gives no hook for is a bare `break` out of an `async for`, which leaves the
324
+ outermost stage suspended. Wrap the iterator when that matters:
325
+
326
+ ```python
327
+ from contextlib import aclosing
328
+
329
+ async with aclosing(stream.map(fetch).__aiter__()) as items:
330
+ async for item in items:
331
+ if done(item):
332
+ break
333
+ ```
334
+
335
+ ## Typing
336
+
337
+ `Stream[T]` is generic and ships a `py.typed` marker, so element types flow through a chain:
338
+
339
+ ```python
340
+ Stream.of(1, 2, 3) # Stream[int]
341
+ .map(str) # Stream[str]
342
+ .to_list() # list[str]
343
+ ```
344
+
345
+ Some ops are restricted by self-type and fail at type-check time, not runtime:
346
+
347
+ - `.sum()` only on numeric streams
348
+ - `.join()` only on `Stream[str]`
349
+ - `.to_set()`, `.distinct()`, `.group_by()` require hashable items
350
+
351
+ ## Development
352
+
353
+ ```bash
354
+ uv sync # install project + dev tools (needs Python 3.11+)
355
+ uv run pytest # tests
356
+ uv run ruff check . # lint
357
+ uv run mypy # type check (strict)
358
+ ```
359
+
360
+ Run everything the way CI does:
361
+
362
+ ```bash
363
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
364
+ ```
365
+
366
+ ## Versioning
367
+
368
+ Streamlet follows [semantic versioning](https://semver.org/spec/v2.0.0.html). Before 1.0.0 a
369
+ breaking change bumps the minor version and everything else bumps the patch version. Changes
370
+ are recorded in [CHANGELOG.md](CHANGELOG.md).
371
+
372
+ Releases are cut from a tag: pushing `vX.Y.Z` builds the artifacts and checks the tag against the
373
+ version in `pyproject.toml`. Publishing the GitHub release for that tag uploads them to PyPI
374
+ through trusted publishing, so no token lives in this repository.
375
+
376
+ ## Roadmap
377
+
378
+ - [x] Core `Stream[T]`, intermediate + terminal ops, laziness tests
379
+ - [x] `__or__` pipe chaining, constructors, `group_by`, edge cases
380
+ - [x] `Stream.from_file` — context-managed resource streams
381
+ - [x] `AsyncStream` — bounded concurrent `map`
382
+ - [x] Docstrings, README examples
383
+ - [ ] PyPI release
384
+
385
+ ## License
386
+
387
+ [MIT](LICENSE)
@@ -0,0 +1,10 @@
1
+ streamlet/__init__.py,sha256=nWV1AIAUuNb3pdALPQu3vMje44_-Q7pdnzEEFNB4WzQ,901
2
+ streamlet/async_stream.py,sha256=-4U0QGp-196F0KkRmrK0axLMS27rSDyX2R6PD0bVlCo,18566
3
+ streamlet/file_stream.py,sha256=KtkFH0dpvAmqXkOoMKWSnGrxCIUhsN19N6wdGM0fujQ,5532
4
+ streamlet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ streamlet/stream.py,sha256=VAYQ3Vm4Jh7Yrf2VR0GE6xClFhMSH-d64zYk5dEUQuQ,15645
6
+ pystreamlet-0.1.0.dist-info/licenses/LICENSE,sha256=IZW3gS-eGHikKwMWk0GN7Z2HwPh8DPam9vxb1L7SHQE,1069
7
+ pystreamlet-0.1.0.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
8
+ pystreamlet-0.1.0.dist-info/entry_points.txt,sha256=cuCNK35Vx6qfGKF6eI7jZRGyISmDsIfHj_ozom1pt_A,46
9
+ pystreamlet-0.1.0.dist-info/METADATA,sha256=ngLgmgtstw8CcwpGnm5ka07gCVnxnjRt9XhSPFUHBug,12905
10
+ pystreamlet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ streamlet = streamlet:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 0xthreadsafe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
streamlet/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Streamlet — a fluent, lazy stream-processing library for Python.
2
+
3
+ Start from :class:`~streamlet.stream.Stream` for synchronous pipelines and
4
+ :class:`~streamlet.async_stream.AsyncStream` for async ones::
5
+
6
+ from streamlet import Stream
7
+
8
+ Stream.from_iterable(range(100)).filter(lambda n: n % 2 == 0).take(5).to_list()
9
+
10
+ :class:`~streamlet.file_stream.ResourceStream` (and its ``FileStream``
11
+ specialisation) covers sources that hold a resource open.
12
+ """
13
+
14
+ from streamlet.async_stream import AsyncStream
15
+ from streamlet.file_stream import ClosableIterable, FileStream, ResourceStream
16
+ from streamlet.stream import Stream, StreamConsumedError
17
+
18
+ __all__ = [
19
+ "AsyncStream",
20
+ "ClosableIterable",
21
+ "FileStream",
22
+ "ResourceStream",
23
+ "Stream",
24
+ "StreamConsumedError",
25
+ ]
26
+
27
+
28
+ def main() -> None:
29
+ """Entry point for the ``streamlet`` console script."""
30
+ print("Hello from streamlet!")