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.
Files changed (86) hide show
  1. streamable-2.0.0/PKG-INFO +649 -0
  2. streamable-2.0.0/README.md +614 -0
  3. streamable-2.0.0/pyproject.toml +55 -0
  4. streamable-2.0.0/streamable/__init__.py +13 -0
  5. streamable-2.0.0/streamable/_afunctions.py +180 -0
  6. streamable-2.0.0/streamable/_aiterators.py +1006 -0
  7. streamable-2.0.0/streamable/_functions.py +156 -0
  8. streamable-2.0.0/streamable/_iterators.py +833 -0
  9. streamable-2.0.0/streamable/_stream.py +1292 -0
  10. streamable-2.0.0/streamable/_tools/_afuture.py +85 -0
  11. streamable-2.0.0/streamable/_tools/_async.py +27 -0
  12. streamable-2.0.0/streamable/_tools/_error.py +75 -0
  13. streamable-2.0.0/streamable/_tools/_func.py +69 -0
  14. streamable-2.0.0/streamable/_tools/_future.py +81 -0
  15. streamable-2.0.0/streamable/_tools/_iter.py +105 -0
  16. streamable-2.0.0/streamable/_tools/_logging.py +22 -0
  17. streamable-2.0.0/streamable/_tools/_observation.py +34 -0
  18. streamable-2.0.0/streamable/_tools/_sentinel.py +4 -0
  19. streamable-2.0.0/streamable/_tools/_star.py +83 -0
  20. streamable-2.0.0/streamable/_tools/_validation.py +46 -0
  21. {streamable-1.6.6 → streamable-2.0.0}/streamable/visitors/__init__.py +2 -0
  22. streamable-2.0.0/streamable/visitors/_aiter.py +137 -0
  23. streamable-2.0.0/streamable/visitors/_base.py +63 -0
  24. streamable-2.0.0/streamable/visitors/_eq.py +123 -0
  25. streamable-2.0.0/streamable/visitors/_involves_async.py +66 -0
  26. streamable-2.0.0/streamable/visitors/_iter.py +129 -0
  27. streamable-2.0.0/streamable/visitors/_repr.py +116 -0
  28. streamable-2.0.0/streamable.egg-info/PKG-INFO +649 -0
  29. streamable-2.0.0/streamable.egg-info/SOURCES.txt +53 -0
  30. streamable-2.0.0/streamable.egg-info/requires.txt +28 -0
  31. streamable-2.0.0/tests/test_buffer.py +80 -0
  32. streamable-2.0.0/tests/test_catch.py +269 -0
  33. streamable-2.0.0/tests/test_do.py +29 -0
  34. streamable-2.0.0/tests/test_eq.py +124 -0
  35. streamable-2.0.0/tests/test_filter.py +23 -0
  36. streamable-2.0.0/tests/test_flatten.py +141 -0
  37. streamable-2.0.0/tests/test_group.py +248 -0
  38. streamable-2.0.0/tests/test_map.py +179 -0
  39. streamable-2.0.0/tests/test_observe.py +273 -0
  40. streamable-2.0.0/tests/test_other.py +319 -0
  41. streamable-2.0.0/tests/test_readme.py +405 -0
  42. streamable-2.0.0/tests/test_ref_cycles.py +85 -0
  43. streamable-2.0.0/tests/test_repr.py +127 -0
  44. streamable-2.0.0/tests/test_skip.py +53 -0
  45. streamable-2.0.0/tests/test_take.py +86 -0
  46. streamable-2.0.0/tests/test_throttle.py +73 -0
  47. streamable-2.0.0/tests/test_tools.py +90 -0
  48. streamable-2.0.0/tests/test_visitor.py +52 -0
  49. streamable-1.6.6/PKG-INFO +0 -808
  50. streamable-1.6.6/README.md +0 -789
  51. streamable-1.6.6/setup.py +0 -17
  52. streamable-1.6.6/streamable/__init__.py +0 -4
  53. streamable-1.6.6/streamable/_aiterators.py +0 -918
  54. streamable-1.6.6/streamable/_iterators.py +0 -886
  55. streamable-1.6.6/streamable/_util/_asynctools.py +0 -21
  56. streamable-1.6.6/streamable/_util/_constants.py +0 -1
  57. streamable-1.6.6/streamable/_util/_errortools.py +0 -18
  58. streamable-1.6.6/streamable/_util/_functiontools.py +0 -156
  59. streamable-1.6.6/streamable/_util/_futuretools.py +0 -140
  60. streamable-1.6.6/streamable/_util/_iterabletools.py +0 -70
  61. streamable-1.6.6/streamable/_util/_loggertools.py +0 -17
  62. streamable-1.6.6/streamable/_util/_protocols.py +0 -14
  63. streamable-1.6.6/streamable/_util/_validationtools.py +0 -69
  64. streamable-1.6.6/streamable/afunctions.py +0 -307
  65. streamable-1.6.6/streamable/functions.py +0 -305
  66. streamable-1.6.6/streamable/stream.py +0 -1234
  67. streamable-1.6.6/streamable/visitors/_aiterator.py +0 -227
  68. streamable-1.6.6/streamable/visitors/_base.py +0 -79
  69. streamable-1.6.6/streamable/visitors/_equality.py +0 -198
  70. streamable-1.6.6/streamable/visitors/_iterator.py +0 -256
  71. streamable-1.6.6/streamable/visitors/_representation.py +0 -216
  72. streamable-1.6.6/streamable.egg-info/PKG-INFO +0 -808
  73. streamable-1.6.6/streamable.egg-info/SOURCES.txt +0 -37
  74. streamable-1.6.6/tests/test_functions.py +0 -31
  75. streamable-1.6.6/tests/test_iterators.py +0 -37
  76. streamable-1.6.6/tests/test_readme.py +0 -390
  77. streamable-1.6.6/tests/test_stream.py +0 -2935
  78. streamable-1.6.6/tests/test_util.py +0 -41
  79. streamable-1.6.6/tests/test_visitor.py +0 -74
  80. {streamable-1.6.6 → streamable-2.0.0}/LICENSE +0 -0
  81. {streamable-1.6.6 → streamable-2.0.0}/setup.cfg +0 -0
  82. {streamable-1.6.6/streamable/_util → streamable-2.0.0/streamable/_tools}/__init__.py +0 -0
  83. /streamable-1.6.6/streamable/_util/_contextmanagertools.py → /streamable-2.0.0/streamable/_tools/_context.py +0 -0
  84. {streamable-1.6.6 → streamable-2.0.0}/streamable/py.typed +0 -0
  85. {streamable-1.6.6 → streamable-2.0.0}/streamable.egg-info/dependency_links.txt +0 -0
  86. {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
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/release/python-3820/)
43
+ [![PyPI version](https://img.shields.io/pypi/v/streamable.svg)](https://pypi.org/project/streamable/)
44
+ [![Anaconda-Server Badge](https://anaconda.org/conda-forge/streamable/badges/version.svg?)](https://anaconda.org/conda-forge/streamable)
45
+ [![coverage](https://codecov.io/gh/ebonnal/streamable/graph/badge.svg?token=S62T0JQK9N)](https://codecov.io/gh/ebonnal/streamable)
46
+ [![readthedocs](https://app.readthedocs.org/projects/streamable/badge/?version=latest&style=social)](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/)