streamable 1.6.6__tar.gz → 2.0.0__tar.gz
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.
- streamable-2.0.0/PKG-INFO +649 -0
- streamable-2.0.0/README.md +614 -0
- streamable-2.0.0/pyproject.toml +55 -0
- streamable-2.0.0/streamable/__init__.py +13 -0
- streamable-2.0.0/streamable/_afunctions.py +180 -0
- streamable-2.0.0/streamable/_aiterators.py +1006 -0
- streamable-2.0.0/streamable/_functions.py +156 -0
- streamable-2.0.0/streamable/_iterators.py +833 -0
- streamable-2.0.0/streamable/_stream.py +1292 -0
- streamable-2.0.0/streamable/_tools/_afuture.py +85 -0
- streamable-2.0.0/streamable/_tools/_async.py +27 -0
- streamable-2.0.0/streamable/_tools/_error.py +75 -0
- streamable-2.0.0/streamable/_tools/_func.py +69 -0
- streamable-2.0.0/streamable/_tools/_future.py +81 -0
- streamable-2.0.0/streamable/_tools/_iter.py +105 -0
- streamable-2.0.0/streamable/_tools/_logging.py +22 -0
- streamable-2.0.0/streamable/_tools/_observation.py +34 -0
- streamable-2.0.0/streamable/_tools/_sentinel.py +4 -0
- streamable-2.0.0/streamable/_tools/_star.py +83 -0
- streamable-2.0.0/streamable/_tools/_validation.py +46 -0
- {streamable-1.6.6 → streamable-2.0.0}/streamable/visitors/__init__.py +2 -0
- streamable-2.0.0/streamable/visitors/_aiter.py +137 -0
- streamable-2.0.0/streamable/visitors/_base.py +63 -0
- streamable-2.0.0/streamable/visitors/_eq.py +123 -0
- streamable-2.0.0/streamable/visitors/_involves_async.py +66 -0
- streamable-2.0.0/streamable/visitors/_iter.py +129 -0
- streamable-2.0.0/streamable/visitors/_repr.py +116 -0
- streamable-2.0.0/streamable.egg-info/PKG-INFO +649 -0
- streamable-2.0.0/streamable.egg-info/SOURCES.txt +53 -0
- streamable-2.0.0/streamable.egg-info/requires.txt +28 -0
- streamable-2.0.0/tests/test_buffer.py +80 -0
- streamable-2.0.0/tests/test_catch.py +269 -0
- streamable-2.0.0/tests/test_do.py +29 -0
- streamable-2.0.0/tests/test_eq.py +124 -0
- streamable-2.0.0/tests/test_filter.py +23 -0
- streamable-2.0.0/tests/test_flatten.py +141 -0
- streamable-2.0.0/tests/test_group.py +248 -0
- streamable-2.0.0/tests/test_map.py +179 -0
- streamable-2.0.0/tests/test_observe.py +273 -0
- streamable-2.0.0/tests/test_other.py +319 -0
- streamable-2.0.0/tests/test_readme.py +405 -0
- streamable-2.0.0/tests/test_ref_cycles.py +85 -0
- streamable-2.0.0/tests/test_repr.py +127 -0
- streamable-2.0.0/tests/test_skip.py +53 -0
- streamable-2.0.0/tests/test_take.py +86 -0
- streamable-2.0.0/tests/test_throttle.py +73 -0
- streamable-2.0.0/tests/test_tools.py +90 -0
- streamable-2.0.0/tests/test_visitor.py +52 -0
- streamable-1.6.6/PKG-INFO +0 -808
- streamable-1.6.6/README.md +0 -789
- streamable-1.6.6/setup.py +0 -17
- streamable-1.6.6/streamable/__init__.py +0 -4
- streamable-1.6.6/streamable/_aiterators.py +0 -918
- streamable-1.6.6/streamable/_iterators.py +0 -886
- streamable-1.6.6/streamable/_util/_asynctools.py +0 -21
- streamable-1.6.6/streamable/_util/_constants.py +0 -1
- streamable-1.6.6/streamable/_util/_errortools.py +0 -18
- streamable-1.6.6/streamable/_util/_functiontools.py +0 -156
- streamable-1.6.6/streamable/_util/_futuretools.py +0 -140
- streamable-1.6.6/streamable/_util/_iterabletools.py +0 -70
- streamable-1.6.6/streamable/_util/_loggertools.py +0 -17
- streamable-1.6.6/streamable/_util/_protocols.py +0 -14
- streamable-1.6.6/streamable/_util/_validationtools.py +0 -69
- streamable-1.6.6/streamable/afunctions.py +0 -307
- streamable-1.6.6/streamable/functions.py +0 -305
- streamable-1.6.6/streamable/stream.py +0 -1234
- streamable-1.6.6/streamable/visitors/_aiterator.py +0 -227
- streamable-1.6.6/streamable/visitors/_base.py +0 -79
- streamable-1.6.6/streamable/visitors/_equality.py +0 -198
- streamable-1.6.6/streamable/visitors/_iterator.py +0 -256
- streamable-1.6.6/streamable/visitors/_representation.py +0 -216
- streamable-1.6.6/streamable.egg-info/PKG-INFO +0 -808
- streamable-1.6.6/streamable.egg-info/SOURCES.txt +0 -37
- streamable-1.6.6/tests/test_functions.py +0 -31
- streamable-1.6.6/tests/test_iterators.py +0 -37
- streamable-1.6.6/tests/test_readme.py +0 -390
- streamable-1.6.6/tests/test_stream.py +0 -2935
- streamable-1.6.6/tests/test_util.py +0 -41
- streamable-1.6.6/tests/test_visitor.py +0 -74
- {streamable-1.6.6 → streamable-2.0.0}/LICENSE +0 -0
- {streamable-1.6.6 → streamable-2.0.0}/setup.cfg +0 -0
- {streamable-1.6.6/streamable/_util → streamable-2.0.0/streamable/_tools}/__init__.py +0 -0
- /streamable-1.6.6/streamable/_util/_contextmanagertools.py → /streamable-2.0.0/streamable/_tools/_context.py +0 -0
- {streamable-1.6.6 → streamable-2.0.0}/streamable/py.typed +0 -0
- {streamable-1.6.6 → streamable-2.0.0}/streamable.egg-info/dependency_links.txt +0 -0
- {streamable-1.6.6 → streamable-2.0.0}/streamable.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: streamable
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: sync/async iterable streams for Python
|
|
5
|
+
Author-email: ebonnal <bonnal.enzo.dev@gmail.com>
|
|
6
|
+
License: Apache 2.
|
|
7
|
+
Project-URL: Homepage, https://github.com/ebonnal/streamable
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: httpx==0.28.1; python_version >= "3.9" and extra == "dev"
|
|
13
|
+
Requires-Dist: mypy==1.18.2; python_version >= "3.9" and extra == "dev"
|
|
14
|
+
Requires-Dist: mypy-extensions==1.0.0; python_version >= "3.9" and extra == "dev"
|
|
15
|
+
Requires-Dist: polars==1.36.1; python_version >= "3.9" and extra == "dev"
|
|
16
|
+
Requires-Dist: pytest==7.4.4; python_version >= "3.9" and extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio==0.21.2; python_version >= "3.9" and extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov==7.0.0; python_version >= "3.9" and extra == "dev"
|
|
19
|
+
Requires-Dist: respx==0.22.0; python_version >= "3.9" and extra == "dev"
|
|
20
|
+
Requires-Dist: ruff==0.9.10; python_version >= "3.9" and extra == "dev"
|
|
21
|
+
Requires-Dist: typing-extensions==4.12.2; python_version >= "3.9" and extra == "dev"
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest==7.4.4; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-asyncio==0.21.2; extra == "test"
|
|
25
|
+
Requires-Dist: httpx; extra == "test"
|
|
26
|
+
Requires-Dist: respx; extra == "test"
|
|
27
|
+
Requires-Dist: polars; extra == "test"
|
|
28
|
+
Provides-Extra: docs
|
|
29
|
+
Requires-Dist: sphinx>=7; extra == "docs"
|
|
30
|
+
Requires-Dist: furo; extra == "docs"
|
|
31
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.23.0; extra == "docs"
|
|
32
|
+
Requires-Dist: myst-parser>=2; extra == "docs"
|
|
33
|
+
Requires-Dist: sphinx-copybutton; extra == "docs"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# ༄ `streamable`
|
|
37
|
+
|
|
38
|
+
> sync/async iterable streams for Python
|
|
39
|
+
|
|
40
|
+
`stream[T]` wraps any `Iterable[T]` or `AsyncIterable[T]` with a lazy fluent interface covering concurrency, batching, buffering, rate limiting, progress logging, and error handling.
|
|
41
|
+
|
|
42
|
+
[](https://www.python.org/downloads/release/python-3820/)
|
|
43
|
+
[](https://pypi.org/project/streamable/)
|
|
44
|
+
[](https://anaconda.org/conda-forge/streamable)
|
|
45
|
+
[](https://codecov.io/gh/ebonnal/streamable)
|
|
46
|
+
[](https://streamable.readthedocs.io/en/latest/api.html)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# 1. install
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install streamable
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
# 2. import
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from streamable import stream
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
# 3. init
|
|
62
|
+
|
|
63
|
+
Create a `stream[T]` from an `Iterable[T]` (or `AsyncIterable[T]`):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
ints: stream[int] = stream(range(10))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
# 4. operate
|
|
70
|
+
|
|
71
|
+
Chain lazy operations:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import logging
|
|
75
|
+
from datetime import timedelta
|
|
76
|
+
import httpx
|
|
77
|
+
from httpx import Response, HTTPStatusError
|
|
78
|
+
from streamable import stream
|
|
79
|
+
|
|
80
|
+
pokemons: stream[str] = (
|
|
81
|
+
stream(range(10))
|
|
82
|
+
.map(lambda i: f"https://pokeapi.co/api/v2/pokemon-species/{i}")
|
|
83
|
+
.throttle(5, per=timedelta(seconds=1))
|
|
84
|
+
.map(httpx.get, concurrency=2)
|
|
85
|
+
.do(Response.raise_for_status)
|
|
86
|
+
.catch(HTTPStatusError, do=logging.warning)
|
|
87
|
+
.map(lambda poke: poke.json()["name"])
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Source elements will be processed on-the-fly during iteration.
|
|
92
|
+
|
|
93
|
+
Operations accept both sync and async functions.
|
|
94
|
+
|
|
95
|
+
# 5. iterate
|
|
96
|
+
|
|
97
|
+
A `stream[T]` is `Iterable[T]` (and `AsyncIterable[T]`):
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
>>> list(pokemons)
|
|
101
|
+
['bulbasaur', 'ivysaur', 'venusaur', 'charmander', 'charmeleon', 'charizard', 'squirtle', 'wartortle', 'blastoise']
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
# 📒 Operations ([docs](https://streamable.readthedocs.io/en/latest/api.html))
|
|
105
|
+
|
|
106
|
+
- function application
|
|
107
|
+
- [`.map`](#-map)
|
|
108
|
+
- [`.do`](#-do)
|
|
109
|
+
- (un)grouping
|
|
110
|
+
- [`.group`](#-group)
|
|
111
|
+
- [`.flatten`](#-flatten)
|
|
112
|
+
- filtering
|
|
113
|
+
- [`.filter`](#-filter)
|
|
114
|
+
- [`.take`](#-take)
|
|
115
|
+
- [`.skip`](#-skip)
|
|
116
|
+
- control
|
|
117
|
+
- [`.catch`](#-catch)
|
|
118
|
+
- [`.throttle`](#-throttle)
|
|
119
|
+
- [`.buffer`](#-buffer)
|
|
120
|
+
- [`.observe`](#-observe)
|
|
121
|
+
|
|
122
|
+
Operations accept both sync and async functions, they can be mixed within the same `stream`, that can then be consumed as an `Iterable` or `AsyncIterable`. Async functions run in the current loop, one is created if needed.
|
|
123
|
+
|
|
124
|
+
Operations are implemented so that the iteration can resume after an exception.
|
|
125
|
+
|
|
126
|
+
A `stream` can be iterated several times if its source allows it.
|
|
127
|
+
|
|
128
|
+
A `stream` exposes operations to manipulate its elements, but the I/O is not its responsibility. It's meant to be combined with dedicated libraries like `pyarrow`, `psycopg2`, `boto3`, `dlt` ([ETL example](#eg-etl-via-dlt)) ...
|
|
129
|
+
|
|
130
|
+
## ▼ `.map`
|
|
131
|
+
|
|
132
|
+
Transform elements:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
int_chars: stream[str] = stream(range(10)).map(str)
|
|
136
|
+
|
|
137
|
+
assert list(int_chars) == ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
### `concurrency`
|
|
142
|
+
|
|
143
|
+
Set the `concurrency` param to apply the transformation concurrently.
|
|
144
|
+
|
|
145
|
+
Only `concurrency` upstream elements are in-flight for processing.
|
|
146
|
+
|
|
147
|
+
Preserve upstream order unless you set `as_completed=True`.
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
#### via threads
|
|
151
|
+
|
|
152
|
+
If `concurrency > 1`, the transformation will be applied via `concurrency` threads:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
pokemons: stream[str] = (
|
|
156
|
+
stream(range(1, 4))
|
|
157
|
+
.map(lambda i: f"https://pokeapi.co/api/v2/pokemon-species/{i}")
|
|
158
|
+
.map(httpx.get, concurrency=2)
|
|
159
|
+
.map(lambda poke: poke.json()["name"])
|
|
160
|
+
)
|
|
161
|
+
assert list(pokemons) == ['bulbasaur', 'ivysaur', 'venusaur']
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### via `async`
|
|
165
|
+
|
|
166
|
+
If `concurrency > 1` and the transformation is async, it will be applied via `concurrency` async tasks:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# async context
|
|
170
|
+
async with httpx.AsyncClient() as http_client:
|
|
171
|
+
pokemons: stream[str] = (
|
|
172
|
+
stream(range(1, 4))
|
|
173
|
+
.map(lambda i: f"https://pokeapi.co/api/v2/pokemon-species/{i}")
|
|
174
|
+
.map(http_client.get, concurrency=2)
|
|
175
|
+
.map(lambda poke: poke.json()["name"])
|
|
176
|
+
)
|
|
177
|
+
assert [name async for name in pokemons] == ['bulbasaur', 'ivysaur', 'venusaur']
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# sync context
|
|
182
|
+
with asyncio.Runner() as runner:
|
|
183
|
+
http_client = httpx.AsyncClient()
|
|
184
|
+
pokemons: stream[str] = (
|
|
185
|
+
stream(range(1, 4))
|
|
186
|
+
.map(lambda i: f"https://pokeapi.co/api/v2/pokemon-species/{i}")
|
|
187
|
+
.map(http_client.get, concurrency=2)
|
|
188
|
+
.map(lambda poke: poke.json()["name"])
|
|
189
|
+
)
|
|
190
|
+
# uses runner's loop
|
|
191
|
+
assert list(pokemons) == ['bulbasaur', 'ivysaur', 'venusaur']
|
|
192
|
+
runner.run(http_client.aclose())
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### via processes
|
|
196
|
+
|
|
197
|
+
`concurrency` can also be a `concurrent.futures.Executor`, pass a `ProcessPoolExecutor` to apply the transformations via processes:
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
with ProcessPoolExecutor(max_workers=10) as processes:
|
|
203
|
+
state: list[int] = []
|
|
204
|
+
# ints are mapped
|
|
205
|
+
assert list(
|
|
206
|
+
stream(range(10))
|
|
207
|
+
.map(state.append, concurrency=processes)
|
|
208
|
+
) == [None] * 10
|
|
209
|
+
# the `state` of the main process is not mutated
|
|
210
|
+
assert state == []
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## ▼ `.do`
|
|
214
|
+
|
|
215
|
+
Perform side effects:
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
state: list[int] = []
|
|
220
|
+
storing_ints: stream[int] = stream(range(10)).do(state.append)
|
|
221
|
+
|
|
222
|
+
assert list(storing_ints) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
223
|
+
assert state == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### `concurrency`
|
|
227
|
+
|
|
228
|
+
Same as `.map`.
|
|
229
|
+
|
|
230
|
+
## ▼ `.group`
|
|
231
|
+
|
|
232
|
+
Group elements into batches...
|
|
233
|
+
|
|
234
|
+
... `up_to` a given batch size:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
int_batches: stream[list[int]] = stream(range(10)).group(5)
|
|
238
|
+
|
|
239
|
+
assert list(int_batches) == [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
... `within` a given time interval:
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from datetime import timedelta
|
|
247
|
+
|
|
248
|
+
int_1s_batches: stream[list[int]] = (
|
|
249
|
+
stream(range(10))
|
|
250
|
+
.throttle(2, per=timedelta(seconds=1))
|
|
251
|
+
.group(within=timedelta(seconds=0.99))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
assert list(int_1s_batches) == [[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
... `by` a given key, yielding `(key, elements)` pairs:
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
ints_by_parity: stream[tuple[str, list[int]]] = (
|
|
262
|
+
stream(range(10))
|
|
263
|
+
.group(by=lambda n: "odd" if n % 2 else "even")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
assert list(ints_by_parity) == [("even", [0, 2, 4, 6, 8]), ("odd", [1, 3, 5, 7, 9])]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
You can combine these parameters.
|
|
270
|
+
|
|
271
|
+
## ▼ `.flatten`
|
|
272
|
+
|
|
273
|
+
Explode upstream elements (`Iterable` or `AsyncIterable`):
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
chars: stream[str] = stream(["hel", "lo!"]).flatten()
|
|
277
|
+
|
|
278
|
+
assert list(chars) == ["h", "e", "l", "l", "o", "!"]
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### `concurrency`
|
|
282
|
+
|
|
283
|
+
Explode `concurrency` iterables at a time:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
chars: stream[str] = stream(["hel", "lo", "!"]).flatten(concurrency=2)
|
|
287
|
+
|
|
288
|
+
assert list(chars) == ["h", "l", "e", "o", "l", "!"]
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## ▼ `.filter`
|
|
292
|
+
|
|
293
|
+
Filter elements satisfying a predicate:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
even_ints: stream[int] = stream(range(10)).filter(lambda n: n % 2 == 0)
|
|
297
|
+
|
|
298
|
+
assert list(even_ints) == [0, 2, 4, 6, 8]
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## ▼ `.take`
|
|
302
|
+
|
|
303
|
+
Take a given number of elements:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
first_5_ints: stream[int] = stream(range(10)).take(5)
|
|
307
|
+
|
|
308
|
+
assert list(first_5_ints) == [0, 1, 2, 3, 4]
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
... or take `until` a predicate is satisfied:
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
first_5_ints: stream[int] = stream(range(10)).take(until=lambda n: n == 5)
|
|
316
|
+
|
|
317
|
+
assert list(first_5_ints) == [0, 1, 2, 3, 4]
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## ▼ `.skip`
|
|
321
|
+
|
|
322
|
+
Skip a given number of elements:
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
ints_after_5: stream[int] = stream(range(10)).skip(5)
|
|
326
|
+
|
|
327
|
+
assert list(ints_after_5) == [5, 6, 7, 8, 9]
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
... or skip `until` a predicate is satisfied:
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
ints_after_5: stream[int] = stream(range(10)).skip(until=lambda n: n >= 5)
|
|
335
|
+
|
|
336
|
+
assert list(ints_after_5) == [5, 6, 7, 8, 9]
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## ▼ `.catch`
|
|
340
|
+
|
|
341
|
+
Catch exceptions of a given type:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
inverses: stream[float] = (
|
|
345
|
+
stream(range(10))
|
|
346
|
+
.map(lambda n: round(1 / n, 2))
|
|
347
|
+
.catch(ZeroDivisionError)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
assert list(inverses) == [1.0, 0.5, 0.33, 0.25, 0.2, 0.17, 0.14, 0.12, 0.11]
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
... `where` a predicate is satisfied:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
from http import HTTPStatus
|
|
357
|
+
|
|
358
|
+
urls = [
|
|
359
|
+
"https://github.com/ebonnal",
|
|
360
|
+
"https://github.com/ebonnal/streamable",
|
|
361
|
+
"https://github.com/ebonnal/foo",
|
|
362
|
+
]
|
|
363
|
+
responses: stream[httpx.Response] = (
|
|
364
|
+
stream(urls)
|
|
365
|
+
.map(httpx.get)
|
|
366
|
+
.do(httpx.Response.raise_for_status)
|
|
367
|
+
.catch(
|
|
368
|
+
httpx.HTTPStatusError,
|
|
369
|
+
where=lambda e: e.response.status_code == HTTPStatus.NOT_FOUND,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
assert len(list(responses)) == 2
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
... `do` a side effect on catch:
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
errors: list[Exception] = []
|
|
379
|
+
inverses: stream[float] = (
|
|
380
|
+
stream(range(10))
|
|
381
|
+
.map(lambda n: round(1 / n, 2))
|
|
382
|
+
.catch(ZeroDivisionError, do=errors.append)
|
|
383
|
+
)
|
|
384
|
+
assert list(inverses) == [1.0, 0.5, 0.33, 0.25, 0.2, 0.17, 0.14, 0.12, 0.11]
|
|
385
|
+
assert len(errors) == 1
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
... `replace` with a value:
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
inverses: stream[float] = (
|
|
392
|
+
stream(range(10))
|
|
393
|
+
.map(lambda n: round(1 / n, 2))
|
|
394
|
+
.catch(ZeroDivisionError, replace=lambda e: float("inf"))
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
assert list(inverses) == [float("inf"), 1.0, 0.5, 0.33, 0.25, 0.2, 0.17, 0.14, 0.12, 0.11]
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
... `stop=True` to stop the iteration if an exception is caught:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
inverses: stream[float] = (
|
|
404
|
+
stream(range(10))
|
|
405
|
+
.map(lambda n: round(1 / n, 2))
|
|
406
|
+
.catch(ZeroDivisionError, stop=True)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
assert list(inverses) == []
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
You can combine these parameters.
|
|
413
|
+
|
|
414
|
+
## ▼ `.throttle`
|
|
415
|
+
|
|
416
|
+
Limit the number of emissions `per` time interval (sliding window):
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
from datetime import timedelta
|
|
420
|
+
|
|
421
|
+
throttled_ints: stream[int] = stream(range(10)).throttle(3, per=timedelta(seconds=1))
|
|
422
|
+
|
|
423
|
+
# takes 3 seconds
|
|
424
|
+
assert list(throttled_ints) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## ▼ `.buffer`
|
|
428
|
+
|
|
429
|
+
Buffer upstream elements into a bounded queue via a background task (decoupling upstream production rate from downstream consumption rate):
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
pulled: list[int] = []
|
|
433
|
+
buffered_ints = iter(
|
|
434
|
+
stream(range(10))
|
|
435
|
+
.do(pulled.append)
|
|
436
|
+
.buffer(5)
|
|
437
|
+
)
|
|
438
|
+
assert next(buffered_ints) == 0
|
|
439
|
+
time.sleep(1e-3)
|
|
440
|
+
assert pulled == [0, 1, 2, 3, 4, 5]
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## ▼ `.observe`
|
|
444
|
+
|
|
445
|
+
Observe the iteration progress:
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
observed_ints: stream[int] = stream(range(10)).observe("ints")
|
|
449
|
+
assert list(observed_ints) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
logs:
|
|
453
|
+
```
|
|
454
|
+
2025-12-23T16:43:07Z INFO observed=ints elapsed=0:00:00.000019 errors=0 elements=1
|
|
455
|
+
2025-12-23T16:43:07Z INFO observed=ints elapsed=0:00:00.001117 errors=0 elements=2
|
|
456
|
+
2025-12-23T16:43:07Z INFO observed=ints elapsed=0:00:00.001147 errors=0 elements=4
|
|
457
|
+
2025-12-23T16:43:07Z INFO observed=ints elapsed=0:00:00.001162 errors=0 elements=8
|
|
458
|
+
2025-12-23T16:43:07Z INFO observed=ints elapsed=0:00:00.001179 errors=0 elements=10
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Logs are produced when the counts reach powers of 2. Set `every` to produce them periodically:
|
|
462
|
+
```python
|
|
463
|
+
# observe every 1k elements (or errors)
|
|
464
|
+
observed_ints = stream(range(10)).observe("ints", every=1000)
|
|
465
|
+
# observe every 5 seconds
|
|
466
|
+
observed_ints = stream(range(10)).observe("ints", every=timedelta(seconds=5))
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Observations are logged via `logging.getLogger("streamable").info`. Set `do` to do something else with the `streamable.Observation`:
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
observed_ints = stream(range(10)).observe("ints", do=custom_logger.info)
|
|
473
|
+
observed_ints = stream(range(10)).observe("ints", do=observations.append)
|
|
474
|
+
observed_ints = stream(range(10)).observe("ints", do=print)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
## ▼ `+`
|
|
479
|
+
|
|
480
|
+
Concatenate a stream with an iterable:
|
|
481
|
+
```python
|
|
482
|
+
concatenated_ints = stream(range(10)) + range(10)
|
|
483
|
+
assert list(concatenated_ints) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## ▼ `.cast`
|
|
487
|
+
|
|
488
|
+
Provide a type hint for elements:
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
docs: stream[Any] = stream(['{"foo": "bar"}', '{"foo": "baz"}']).map(json.loads)
|
|
492
|
+
dicts: stream[dict[str, str]] = docs.cast(dict[str, str])
|
|
493
|
+
# the stream remains the same, it's for type checkers only
|
|
494
|
+
assert dicts is docs
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## ▼ `.__call__`
|
|
498
|
+
|
|
499
|
+
Iterate as an `Iterable` until exhaustion, without collecting its elements:
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
state: list[int] = []
|
|
503
|
+
pipeline: stream[int] = stream(range(10)).do(state.append)
|
|
504
|
+
|
|
505
|
+
pipeline()
|
|
506
|
+
|
|
507
|
+
assert state == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
## ▼ `await`
|
|
511
|
+
|
|
512
|
+
Iterate as an `AsyncIterable` until exhaustion, without collecting its elements:
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
state: list[int] = []
|
|
516
|
+
pipeline: stream[int] = stream(range(10)).do(state.append)
|
|
517
|
+
|
|
518
|
+
await pipeline
|
|
519
|
+
|
|
520
|
+
assert state == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
## ▼ `.pipe`
|
|
524
|
+
|
|
525
|
+
Apply a callable, passing the stream as first argument, followed by the provided `*args` and `**kwargs`:
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
import polars as pl
|
|
529
|
+
|
|
530
|
+
pokemons: stream[str] = ...
|
|
531
|
+
pokemons.pipe(pl.DataFrame, schema=["name"]).write_csv("pokemons.csv")
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
# ••• other notes
|
|
535
|
+
|
|
536
|
+
## function as source
|
|
537
|
+
|
|
538
|
+
A `stream` can also be instantiated from a function (sync or async) that will be called sequentially to get the next source element during iteration.
|
|
539
|
+
|
|
540
|
+
e.g. stream from a `Queue`:
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
queued_ints: queue.Queue[int] = ...
|
|
544
|
+
# or asyncio.Queue[int]
|
|
545
|
+
ints: stream[int] = stream(queued_ints.get)
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## starmap
|
|
549
|
+
|
|
550
|
+
The `star` function decorator transforms a function (sync or async) that takes several positional arguments into a function that takes a tuple.
|
|
551
|
+
|
|
552
|
+
```python
|
|
553
|
+
from streamable import star
|
|
554
|
+
|
|
555
|
+
pokemons: stream[str] = ...
|
|
556
|
+
enumerated_pokes: stream[str] = (
|
|
557
|
+
stream(enumerate(pokemons))
|
|
558
|
+
.map(star(lambda index, poke: f"#{index + 1} {poke}"))
|
|
559
|
+
)
|
|
560
|
+
assert list(enumerated_pokes) == ['#1 bulbasaur', '#2 ivysaur', '#3 venusaur', '#4 charmander', '#5 charmeleon', '#6 charizard', '#7 squirtle', '#8 wartortle', '#9 blastoise']
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
## distinct
|
|
564
|
+
|
|
565
|
+
To collect distinct elements you can `set(a_stream)`.
|
|
566
|
+
|
|
567
|
+
To deduplicates in the middle of the stream, `.filter` new values and `.do` add them into a `set` (or a fancier cache):
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
seen: set[str] = set()
|
|
571
|
+
|
|
572
|
+
unique_ints: stream[int] = (
|
|
573
|
+
stream("001000111")
|
|
574
|
+
.filter(lambda _: _ not in seen)
|
|
575
|
+
.do(seen.add)
|
|
576
|
+
.map(int)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
assert list(unique_ints) == [0, 1]
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
## vs `builtins.map/filter`
|
|
583
|
+
|
|
584
|
+
There is zero overhead during iteration compared to `builtins.map` and `builtins.filter`:
|
|
585
|
+
|
|
586
|
+
```python
|
|
587
|
+
odd_int_chars = stream(range(N)).filter(lambda n: n % 2).map(str)
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
`iter(odd_int_chars)` visits the operations lineage and returns exactly this iterator:
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
map(str, filter(lambda n: n % 2, range(N)))
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## e.g. ETL via [`dlt`](https://github.com/dlt-hub/dlt)
|
|
597
|
+
|
|
598
|
+
A `stream` is an expressive way to declare a `dlt.resource`:
|
|
599
|
+
|
|
600
|
+
```python
|
|
601
|
+
# from datetime import timedelta
|
|
602
|
+
# from http import HTTPStatus
|
|
603
|
+
# from itertools import count
|
|
604
|
+
# import dlt
|
|
605
|
+
# import httpx
|
|
606
|
+
# from httpx import Response, HTTPStatusError
|
|
607
|
+
# from dlt.destinations import filesystem
|
|
608
|
+
# from streamable import stream
|
|
609
|
+
|
|
610
|
+
def not_found(e: HTTPStatusError) -> bool:
|
|
611
|
+
return e.response.status_code == HTTPStatus.NOT_FOUND
|
|
612
|
+
|
|
613
|
+
@dlt.resource
|
|
614
|
+
def pokemons(http_client: httpx.Client, concurrency: int, per_second: int) -> stream[dict]:
|
|
615
|
+
"""Ingest Pokémons from the PokéAPI, stop on first 404."""
|
|
616
|
+
return (
|
|
617
|
+
stream(count(1))
|
|
618
|
+
.map(lambda i: f"https://pokeapi.co/api/v2/pokemon-species/{i}")
|
|
619
|
+
.throttle(per_second, per=timedelta(seconds=1))
|
|
620
|
+
.map(http_client.get, concurrency=concurrency, as_completed=True)
|
|
621
|
+
.do(Response.raise_for_status)
|
|
622
|
+
.catch(HTTPStatusError, where=not_found, stop=True)
|
|
623
|
+
.map(Response.json)
|
|
624
|
+
.observe("pokemons")
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Write to a partitioned Delta Lake table, chunk by chunk on-the-fly.
|
|
628
|
+
with httpx.Client() as http_client:
|
|
629
|
+
dlt.pipeline(
|
|
630
|
+
pipeline_name="ingest_pokeapi",
|
|
631
|
+
destination=filesystem("deltalake"),
|
|
632
|
+
dataset_name="pokeapi",
|
|
633
|
+
).run(
|
|
634
|
+
pokemons(http_client, concurrency=8, per_second=32),
|
|
635
|
+
table_format="delta",
|
|
636
|
+
write_disposition='merge',
|
|
637
|
+
columns={
|
|
638
|
+
"id": {"primary_key": True},
|
|
639
|
+
"color__name": {"partition": True},
|
|
640
|
+
},
|
|
641
|
+
)
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
# ⭐ links
|
|
645
|
+
|
|
646
|
+
- [Top 10 Python libraries of 2024, from Tryolabs](https://tryolabs.com/blog/top-python-libraries-2024#top-10---general-use) ([LinkedIn](https://www.linkedin.com/posts/tryolabs_top-python-libraries-2024-activity-7273052840984539137-bcGs?utm_source=share&utm_medium=member_desktop), [Reddit](https://www.reddit.com/r/Python/comments/1hbs4t8/the_handpicked_selection_of_the_best_python/))
|
|
647
|
+
- [PyCoder’s weekly](https://pycoders.com/issues/651) x [Real Python](https://realpython.com/)
|
|
648
|
+
- [@PythonHub's tweet](https://x.com/PythonHub/status/1842886311369142713)
|
|
649
|
+
- [Reddit v1.0.0 showcase](https://www.reddit.com/r/Python/comments/1fp38jd/streamable_streamlike_manipulation_of_iterables/)
|