pyarallel 0.1.3__tar.gz → 0.6.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 (89) hide show
  1. pyarallel-0.6.0/.github/workflows/ci.yml +31 -0
  2. {pyarallel-0.1.3 → pyarallel-0.6.0}/.gitignore +9 -2
  3. pyarallel-0.6.0/CHANGELOG.md +62 -0
  4. {pyarallel-0.1.3 → pyarallel-0.6.0}/CONTRIBUTING.md +4 -3
  5. pyarallel-0.6.0/Makefile +45 -0
  6. pyarallel-0.6.0/PKG-INFO +276 -0
  7. pyarallel-0.6.0/README.md +237 -0
  8. pyarallel-0.6.0/docs/api-reference/async.md +224 -0
  9. pyarallel-0.6.0/docs/api-reference/core.md +634 -0
  10. pyarallel-0.6.0/docs/api-reference/rate-limiting.md +87 -0
  11. {pyarallel-0.1.3 → pyarallel-0.6.0}/docs/development/CONTRIBUTING.md +4 -3
  12. pyarallel-0.6.0/docs/development/devx-principles.md +49 -0
  13. pyarallel-0.6.0/docs/development/plans/v0.5.0.md +652 -0
  14. pyarallel-0.6.0/docs/development/plans/v0.6-engine-unification.md +528 -0
  15. pyarallel-0.6.0/docs/development/plans/v0.6-ledger-refactor.md +273 -0
  16. pyarallel-0.6.0/docs/development/roadmap.md +244 -0
  17. pyarallel-0.6.0/docs/getting-started/installation.md +28 -0
  18. pyarallel-0.6.0/docs/getting-started/quickstart.md +186 -0
  19. pyarallel-0.6.0/docs/index.md +57 -0
  20. pyarallel-0.6.0/docs/user-guide/advanced-features.md +350 -0
  21. pyarallel-0.6.0/docs/user-guide/best-practices.md +232 -0
  22. pyarallel-0.6.0/docs/user-guide/real-world-patterns.md +288 -0
  23. pyarallel-0.6.0/examples/01_basic_usage.py +98 -0
  24. pyarallel-0.6.0/examples/02_api_calls_with_rate_limiting.py +128 -0
  25. pyarallel-0.6.0/examples/03_cpu_bound_processing.py +150 -0
  26. pyarallel-0.6.0/examples/04_batch_processing.py +134 -0
  27. pyarallel-0.6.0/examples/05_configuration.py +104 -0
  28. pyarallel-0.6.0/examples/06_resilient_api_jobs.py +104 -0
  29. pyarallel-0.6.0/examples/README.md +115 -0
  30. {pyarallel-0.1.3 → pyarallel-0.6.0}/mkdocs.yml +8 -5
  31. pyarallel-0.6.0/pyarallel/__init__.py +36 -0
  32. pyarallel-0.6.0/pyarallel/_procexec.py +54 -0
  33. pyarallel-0.6.0/pyarallel/_run.py +91 -0
  34. pyarallel-0.6.0/pyarallel/aio.py +607 -0
  35. pyarallel-0.6.0/pyarallel/checkpoint.py +355 -0
  36. pyarallel-0.6.0/pyarallel/core.py +1046 -0
  37. pyarallel-0.6.0/pyarallel/decorators.py +349 -0
  38. pyarallel-0.6.0/pyarallel/limiter.py +163 -0
  39. pyarallel-0.6.0/pyarallel/policies.py +133 -0
  40. pyarallel-0.6.0/pyarallel/py.typed +0 -0
  41. pyarallel-0.6.0/pyarallel/result.py +223 -0
  42. {pyarallel-0.1.3 → pyarallel-0.6.0}/pyproject.toml +20 -21
  43. pyarallel-0.6.0/tests/conftest.py +1 -0
  44. pyarallel-0.6.0/tests/smoke.py +261 -0
  45. pyarallel-0.6.0/tests/test_async.py +195 -0
  46. pyarallel-0.6.0/tests/test_batch.py +410 -0
  47. pyarallel-0.6.0/tests/test_checkpoint.py +618 -0
  48. pyarallel-0.6.0/tests/test_decorator.py +168 -0
  49. pyarallel-0.6.0/tests/test_limiter.py +213 -0
  50. pyarallel-0.6.0/tests/test_max_errors.py +553 -0
  51. pyarallel-0.6.0/tests/test_metadata.py +164 -0
  52. pyarallel-0.6.0/tests/test_parallel_map.py +183 -0
  53. pyarallel-0.6.0/tests/test_parity.py +494 -0
  54. pyarallel-0.6.0/tests/test_result.py +300 -0
  55. pyarallel-0.6.0/tests/test_retry.py +657 -0
  56. pyarallel-0.6.0/tests/test_starmap.py +232 -0
  57. pyarallel-0.6.0/tests/test_streaming.py +625 -0
  58. pyarallel-0.6.0/tests/typing_assertions.py +165 -0
  59. pyarallel-0.6.0/uv.lock +1215 -0
  60. pyarallel-0.1.3/Makefile +0 -36
  61. pyarallel-0.1.3/PKG-INFO +0 -490
  62. pyarallel-0.1.3/README.md +0 -451
  63. pyarallel-0.1.3/TASKS.md +0 -153
  64. pyarallel-0.1.3/docs/api-reference/configuration-api.md +0 -49
  65. pyarallel-0.1.3/docs/api-reference/decorators.md +0 -64
  66. pyarallel-0.1.3/docs/api-reference/rate-limiting.md +0 -60
  67. pyarallel-0.1.3/docs/development/roadmap.md +0 -78
  68. pyarallel-0.1.3/docs/getting-started/installation.md +0 -61
  69. pyarallel-0.1.3/docs/getting-started/quickstart.md +0 -94
  70. pyarallel-0.1.3/docs/index.md +0 -101
  71. pyarallel-0.1.3/docs/user-guide/advanced-features.md +0 -174
  72. pyarallel-0.1.3/docs/user-guide/best-practices.md +0 -207
  73. pyarallel-0.1.3/docs/user-guide/configuration.md +0 -150
  74. pyarallel-0.1.3/pyarallel/__init__.py +0 -10
  75. pyarallel-0.1.3/pyarallel/config.py +0 -154
  76. pyarallel-0.1.3/pyarallel/config_manager.py +0 -368
  77. pyarallel-0.1.3/pyarallel/core.py +0 -414
  78. pyarallel-0.1.3/pyarallel/env_config.py +0 -58
  79. pyarallel-0.1.3/sandbox/sandbox_test.py +0 -91
  80. pyarallel-0.1.3/setup.py +0 -46
  81. pyarallel-0.1.3/tests/conftest.py +0 -120
  82. pyarallel-0.1.3/tests/test_config_manager.py +0 -68
  83. pyarallel-0.1.3/tests/test_decorator_config.py +0 -127
  84. pyarallel-0.1.3/tests/test_env_config.py +0 -68
  85. pyarallel-0.1.3/tests/test_pyarallel.py +0 -184
  86. pyarallel-0.1.3/tests/test_runtime_config.py +0 -119
  87. pyarallel-0.1.3/uv.lock +0 -871
  88. {pyarallel-0.1.3 → pyarallel-0.6.0}/.python-version +0 -0
  89. {pyarallel-0.1.3 → pyarallel-0.6.0}/LICENSE.md +0 -0
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ checks:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install
21
+ run: uv sync --group dev
22
+ - name: Lint
23
+ run: |
24
+ uv run ruff check pyarallel tests
25
+ uv run ruff format --check pyarallel tests
26
+ - name: Type check (strict, package + typing assertions)
27
+ run: uv run mypy pyarallel tests/typing_assertions.py
28
+ - name: Tests
29
+ run: uv run pytest -q
30
+ - name: Docs (strict)
31
+ run: uv run mkdocs build --strict
@@ -44,5 +44,12 @@ htmlcov/
44
44
  *.log
45
45
 
46
46
 
47
- # docs
48
- site
47
+ # docs
48
+ site
49
+
50
+ # Claude Code / local dev
51
+ CLAUDE.md
52
+ AGENTS.md
53
+ .omx/
54
+ interim-feedback.md
55
+ pain_points.md
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 — 2026-07-06
4
+
5
+ **One engine for every execution path.** All collected maps — sync and
6
+ async — now run through the same windowed engine that streaming and
7
+ `max_errors` already used. Behavior changes (pre-1.0, no deprecation
8
+ cycle):
9
+
10
+ - `batch_size` means one thing everywhere: the in-flight admission
11
+ window (default `2 × workers` sync, `2 × concurrency` async). Chunk
12
+ barriers no longer exist anywhere — a slow item never stalls the
13
+ items behind it.
14
+ - Collected maps consume input lazily, one window ahead — generators
15
+ are never materialized. The plain no-kwargs call moves from eager
16
+ upfront submission to windowed admission (benchmarked: parity on
17
+ ms-scale tasks; see the v0.6 plan for numbers).
18
+ - No-drain on stop, everywhere: after a timeout or abort the source
19
+ iterator is never touched again. Unsized inputs return a *shorter*
20
+ result instead of drained-and-appended failure placeholders.
21
+ - New: `ParallelResult.timed_out` / `.aborted` report how the run ended
22
+ (at most one is set — first stop reason wins; both show up in the
23
+ repr). This is the reliable truncation signal for unsized inputs,
24
+ where a timed-out run can contain only successes.
25
+ - `on_progress` with unsized inputs reports items *admitted* so far as
26
+ `total` (previously the final total, because input was materialized).
27
+ Pass a sized input for a real total.
28
+ - Source-iterator errors surface mid-run instead of at materialization,
29
+ and propagate promptly: queued work is cancelled, but sync tasks
30
+ already running cannot be interrupted and may complete — side effects
31
+ included — in the background. Materialize the input first
32
+ (`list(items)`) if you need no-work-after-error guarantees, or use
33
+ the async API (tasks are cancelled and awaited).
34
+ - Exception shape: errors raised from callbacks on the async plain path
35
+ propagate plain — the `ExceptionGroup` wrapper went away with the
36
+ removed `asyncio.TaskGroup`.
37
+ - `parallel_starmap` no longer materializes its input — generators of
38
+ argument tuples stay lazy.
39
+ - Total `timeout=` now binds during cached checkpoint admission too: a
40
+ checkpoint-heavy run can no longer overrun the deadline unnoticed.
41
+ - Fixed: a `TimeoutError` raised by the source iterable itself is an
42
+ input error and propagates — it is no longer repackaged as deadline
43
+ expiry.
44
+
45
+ Full contract and review history:
46
+ [v0.6 Engine Unification Plan](docs/development/plans/v0.6-engine-unification.md).
47
+
48
+ ## 0.5.0 — 2026-07-05
49
+
50
+ Structural quality: sliding-window streaming (`ordered=True`, streaming
51
+ `on_progress`), `max_errors` early abort, `ItemResult.attempts`/
52
+ `.duration`, `ParallelResult.ok_values()`, `checkpoint_key=` (schema v2),
53
+ `sequential=True` debug mode, async total `timeout=`, contextvars
54
+ propagation, `worker_init=`/`max_tasks_per_worker=`, typed decorator
55
+ options via `Unpack[TypedDict]`. Details:
56
+ [v0.5.0 Plan](docs/development/plans/v0.5.0.md). Not published to PyPI.
57
+
58
+ ## 0.4.0 — 2026-06
59
+
60
+ Repositioned as the fan-out layer for rate-limited APIs: server-driven
61
+ backoff (`retry_if`/`wait_from`), shareable `Limiter`, real token bucket
62
+ (`burst=`), `checkpoint=` resume, strict typing. Tag only, not published.
@@ -40,15 +40,16 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
40
40
 
41
41
  ## Use a Consistent Coding Style
42
42
 
43
- * Use [Black](https://github.com/psf/black) for Python code formatting
44
- * Keep line length to 88 characters (Black default)
43
+ * Use `uv run ruff format .` for formatting
44
+ * Use `uv run ruff check .` for linting and import ordering
45
+ * Keep line length to 88 characters
45
46
  * Use type hints for function arguments and return values
46
47
  * Write docstrings for all public functions and classes
47
48
 
48
49
  ## Running Tests
49
50
 
50
51
  ```bash
51
- pytest tests/
52
+ uv run python -m pytest tests/ -q
52
53
  ```
53
54
 
54
55
  ## License
@@ -0,0 +1,45 @@
1
+ .PHONY: help test docs-serve docs-deploy format lint clean build publish
2
+
3
+ help:
4
+ @echo "Available commands:"
5
+ @echo " make test Run pytest suite"
6
+ @echo " make docs-serve Start mkdocs development server"
7
+ @echo " make docs-deploy Deploy documentation to GitHub Pages"
8
+ @echo " make format Format code with ruff"
9
+ @echo " make lint Run ruff check + mypy"
10
+ @echo " make clean Remove build artifacts"
11
+ @echo " make build Build package"
12
+ @echo " make publish Publish package to PyPI"
13
+
14
+ test:
15
+ uv run pytest tests/ -v
16
+
17
+ docs-serve:
18
+ uv run mkdocs serve
19
+
20
+ docs-deploy:
21
+ uv run mkdocs gh-deploy
22
+
23
+ format:
24
+ uv run ruff format .
25
+ uv run ruff check --fix .
26
+
27
+ lint:
28
+ uv run ruff check .
29
+ uv run mypy pyarallel/ tests/typing_assertions.py
30
+
31
+ clean:
32
+ rm -rf build/
33
+ rm -rf dist/
34
+ rm -rf *.egg-info/
35
+ find . -type d -name __pycache__ -exec rm -rf {} +
36
+ find . -type f -name '*.pyc' -delete
37
+ find . -type f -name '*.pyo' -delete
38
+ find . -type f -name '*.pyd' -delete
39
+
40
+ build:
41
+ $(MAKE) clean
42
+ uv run python -m build
43
+
44
+ publish: build
45
+ uv run twine upload dist/*
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyarallel
3
+ Version: 0.6.0
4
+ Summary: The fan-out layer for rate-limited APIs — parallel map with rate limiting, retry, and structured errors. Sync and async, zero dependencies.
5
+ Project-URL: Homepage, https://github.com/oneryalcin/pyarallel
6
+ Project-URL: Repository, https://github.com/oneryalcin/pyarallel.git
7
+ Project-URL: Documentation, https://oneryalcin.github.io/pyarallel
8
+ Author-email: Mehmet Oner Yalcin <oneryalcin@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2025 Pyarallel Contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE.md
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Requires-Python: >=3.12
38
+ Description-Content-Type: text/markdown
39
+
40
+ # Pyarallel
41
+
42
+ [![PyPI version](https://img.shields.io/pypi/v/pyarallel)](https://pypi.org/project/pyarallel/) [![PyPI Downloads](https://static.pepy.tech/badge/pyarallel/month)](https://pepy.tech/project/pyarallel)
43
+
44
+ The fan-out layer for rate-limited APIs. Apply one function to many inputs — with rate limiting, retry, resume, and structured errors. Sync and async.
45
+
46
+ Pyarallel is for "fan out one function over N items" workloads against services that throttle you: LLM calls, embeddings, scraping, SaaS APIs — plus general file processing and data crunching. Everyone hand-rolls the same stack for this: a semaphore, tenacity, a rate limiter, ad-hoc 429 handling. Pyarallel is that stack as one coherent tool. Not DAGs, not queues, not distributed systems. Just `concurrent.futures` and `asyncio` with the policies and result handling already built in.
47
+
48
+ **Zero dependencies. Python 3.12+.**
49
+
50
+ ## Before / After
51
+
52
+ Fetch 10,000 URLs with rate limiting and error handling.
53
+
54
+ **concurrent.futures:**
55
+
56
+ ```python
57
+ import requests
58
+ from concurrent.futures import ThreadPoolExecutor, as_completed
59
+
60
+ def fetch(url):
61
+ return requests.get(url, timeout=10).json()
62
+
63
+ urls = ["https://api.example.com/users/1", "https://api.example.com/users/2", ...]
64
+
65
+ results = [None] * len(urls)
66
+ errors = []
67
+
68
+ with ThreadPoolExecutor(max_workers=10) as pool:
69
+ futures = {pool.submit(fetch, url): i for i, url in enumerate(urls)}
70
+ for f in as_completed(futures):
71
+ i = futures[f]
72
+ try:
73
+ results[i] = f.result()
74
+ except Exception as e:
75
+ errors.append((i, e))
76
+
77
+ # No rate limiting. No retry. No resume. And you still
78
+ # need to wire those yourself every time.
79
+ ```
80
+
81
+ **pyarallel:**
82
+
83
+ ```python
84
+ from pyarallel import parallel_map, RateLimit, Retry
85
+
86
+ result = parallel_map(
87
+ fetch, urls,
88
+ workers=10,
89
+ rate_limit=RateLimit(100, "minute"),
90
+ retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
91
+ )
92
+
93
+ for idx, val in result.successes():
94
+ save(val)
95
+ for idx, exc in result.failures():
96
+ log_error(idx, exc)
97
+ ```
98
+
99
+ Same thing, async:
100
+
101
+ ```python
102
+ import httpx
103
+ from pyarallel import async_parallel_map, RateLimit, Retry
104
+
105
+ async def fetch_async(url):
106
+ async with httpx.AsyncClient() as client:
107
+ return (await client.get(url, timeout=10)).json()
108
+
109
+ result = await async_parallel_map(
110
+ fetch_async, urls,
111
+ concurrency=10,
112
+ rate_limit=RateLimit(100, "minute"),
113
+ retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
114
+ )
115
+ # Same result model — result.ok, result.successes(), result.failures()
116
+ ```
117
+
118
+ ## Install
119
+
120
+ ```bash
121
+ pip install pyarallel
122
+ ```
123
+
124
+ ## What You Get
125
+
126
+ - **Rate limiting** — token bucket with burst, per-second/minute/hour: `rate_limit=RateLimit(100, "minute", burst=20)`
127
+ - **Shared quota** — one `Limiter` instance across calls and functions when the budget belongs to an API key: `rate_limit=Limiter(RateLimit(100, "minute"))`
128
+ - **Retry with backoff** — per-item, exponential, jitter, exception filtering: `retry=Retry(attempts=3, on=(ConnectionError,))`
129
+ - **Server-driven backoff** — honor 429 + `Retry-After`: `retry=Retry(retry_if=..., wait_from=...)`; the wait also pauses the shared limiter so one throttled task slows the whole pool
130
+ - **Checkpoint/resume** — `checkpoint="run.ckpt"`: a crash at item 40,000 resumes instead of restarting from zero; `checkpoint_key=lambda u: u.id` keys rows by identity so evolving inputs keep completed work
131
+ - **Early abort** — `max_errors=10`: a dead API costs tens of calls, not thousands; unrun items are marked `Aborted`, partial results returned
132
+ - **One windowed engine** — every API (collected and streaming, sync and async) admits work through a bounded in-flight window: lazy input, generators never materialized, no batch barriers, a straggler never stalls the items behind it
133
+ - **Streaming** — `parallel_iter` / `async_parallel_iter`: `ordered=True` for input-order yields, per-item `attempts`/`duration`
134
+ - **Structured errors** — `ParallelResult` with `.ok`, `.ok_values()`, `.successes()`, `.failures()`, `.raise_on_failure()`, plus `.timed_out`/`.aborted` for how the run ended
135
+ - **Timeouts** — total wall-clock on sync *and* async (`timeout=30.0`), per-task in async (`task_timeout=5.0`)
136
+ - **Debug mode** — `sequential=True` runs inline: no pool, real stack traces, working breakpoints
137
+ - **Progress callbacks** — `on_progress=lambda done, total: print(f"{done}/{total}")` on collected and streaming APIs
138
+ - **Process executor** — CPU-bound work: `executor="process"`, with `worker_init=` and `max_tasks_per_worker=`
139
+ - **Contextvars propagation** — correlation IDs survive into thread workers
140
+ - **Decorator API** — `@parallel` / `@async_parallel` with `.map()`, `.starmap()`, `.stream()` — fully typed via `Unpack[TypedDict]`
141
+
142
+ ## Quick Start
143
+
144
+ ### Sync
145
+
146
+ ```python
147
+ import requests
148
+ from pyarallel import parallel_map, RateLimit, Retry
149
+
150
+ def fetch(url):
151
+ return requests.get(url, timeout=10).json()
152
+
153
+ # Fan out over a list, get ordered results
154
+ result = parallel_map(fetch, urls, workers=10)
155
+
156
+ # Rate-limited API calls with retry
157
+ def call_api(user_id):
158
+ return requests.get(f"https://api.example.com/users/{user_id}").json()
159
+
160
+ result = parallel_map(
161
+ call_api, user_ids,
162
+ workers=10,
163
+ rate_limit=RateLimit(100, "minute"),
164
+ retry=Retry(attempts=3, backoff=1.0, on=(ConnectionError, TimeoutError)),
165
+ )
166
+
167
+ # CPU-bound with processes
168
+ from PIL import Image
169
+
170
+ def resize_image(path):
171
+ img = Image.open(path)
172
+ img.thumbnail((800, 600))
173
+ img.save(path.replace(".png", "_thumb.png"))
174
+
175
+ result = parallel_map(resize_image, paths, executor="process")
176
+ ```
177
+
178
+ ### Async
179
+
180
+ ```python
181
+ import httpx
182
+ from pyarallel import async_parallel_map
183
+
184
+ async def fetch_async(url):
185
+ async with httpx.AsyncClient() as client:
186
+ return (await client.get(url, timeout=10)).json()
187
+
188
+ result = await async_parallel_map(
189
+ fetch_async, urls, concurrency=20, task_timeout=5.0,
190
+ )
191
+ ```
192
+
193
+ ### Decorator
194
+
195
+ Adds `.map()`, `.starmap()`, `.stream()` without changing the function:
196
+
197
+ ```python
198
+ from pyarallel import parallel, async_parallel, RateLimit
199
+
200
+ @parallel(workers=8, rate_limit=RateLimit(100, "minute"))
201
+ def fetch(url):
202
+ return requests.get(url).json()
203
+
204
+ fetch("http://example.com") # normal call — returns dict
205
+ fetch.map(urls) # parallel — returns ParallelResult
206
+ fetch.stream(urls, batch_size=500) # streaming — yields ItemResult
207
+
208
+ @async_parallel(concurrency=10)
209
+ async def fetch_async(url):
210
+ async with httpx.AsyncClient() as c:
211
+ return (await c.get(url)).json()
212
+
213
+ await fetch_async.map(urls) # async parallel
214
+ ```
215
+
216
+ ### Streaming — Constant Memory
217
+
218
+ For ETL, pipelines, or datasets too large to hold in memory:
219
+
220
+ ```python
221
+ from pyarallel import parallel_iter
222
+
223
+ def transform(row):
224
+ return {"id": row["id"], "name": row["name"].strip().title()}
225
+
226
+ for item in parallel_iter(transform, ten_million_rows, batch_size=1000):
227
+ if item.ok:
228
+ db.save(item.value)
229
+ else:
230
+ log_error(item.index, item.error)
231
+ ```
232
+
233
+ ### Error Handling
234
+
235
+ All errors collected, never silently swallowed:
236
+
237
+ ```python
238
+ def send_email(msg):
239
+ return smtp.send(msg["to"], msg["subject"], msg["body"])
240
+
241
+ result = parallel_map(send_email, messages)
242
+
243
+ if result.ok:
244
+ values = result.values() # list of all results, in order
245
+ else:
246
+ for idx, exc in result.failures():
247
+ log_error(idx, exc)
248
+ result.raise_on_failure() # or raise ExceptionGroup with all errors
249
+ ```
250
+
251
+ ## API Summary
252
+
253
+ | Function | Decorator | Returns | Use case |
254
+ |---|---|---|---|
255
+ | `parallel_map(fn, items)` | `.map(items)` | `ParallelResult` | Results fit in memory |
256
+ | `parallel_starmap(fn, items)` | `.starmap(items)` | `ParallelResult` | Multi-arg, fits in memory |
257
+ | `parallel_iter(fn, items)` | `.stream(items)` | `Iterator[ItemResult]` | Streaming, constant memory |
258
+
259
+ Async mirrors: `async_parallel_map`, `async_parallel_starmap`, `async_parallel_iter`
260
+
261
+ | Config | Example |
262
+ |---|---|
263
+ | `RateLimit(count, per, burst)` | `RateLimit(100, "minute", burst=20)` |
264
+ | `Limiter(rate_limit)` | shared budget: `Limiter(RateLimit(100, "minute"))` |
265
+ | `Retry(attempts, backoff, on, retry_if, wait_from)` | `Retry(attempts=3, on=(ConnectionError,))` |
266
+ | `checkpoint=` | resumable runs: `checkpoint="run.ckpt"` |
267
+
268
+ Works with instance methods and static methods via `@parallel` decorator — see [full docs](https://oneryalcin.github.io/pyarallel/).
269
+
270
+ ## Documentation
271
+
272
+ [Full docs](https://oneryalcin.github.io/pyarallel/) — API reference, advanced features, best practices.
273
+
274
+ ## License
275
+
276
+ MIT — see [LICENSE.md](LICENSE.md).
@@ -0,0 +1,237 @@
1
+ # Pyarallel
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/pyarallel)](https://pypi.org/project/pyarallel/) [![PyPI Downloads](https://static.pepy.tech/badge/pyarallel/month)](https://pepy.tech/project/pyarallel)
4
+
5
+ The fan-out layer for rate-limited APIs. Apply one function to many inputs — with rate limiting, retry, resume, and structured errors. Sync and async.
6
+
7
+ Pyarallel is for "fan out one function over N items" workloads against services that throttle you: LLM calls, embeddings, scraping, SaaS APIs — plus general file processing and data crunching. Everyone hand-rolls the same stack for this: a semaphore, tenacity, a rate limiter, ad-hoc 429 handling. Pyarallel is that stack as one coherent tool. Not DAGs, not queues, not distributed systems. Just `concurrent.futures` and `asyncio` with the policies and result handling already built in.
8
+
9
+ **Zero dependencies. Python 3.12+.**
10
+
11
+ ## Before / After
12
+
13
+ Fetch 10,000 URLs with rate limiting and error handling.
14
+
15
+ **concurrent.futures:**
16
+
17
+ ```python
18
+ import requests
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+
21
+ def fetch(url):
22
+ return requests.get(url, timeout=10).json()
23
+
24
+ urls = ["https://api.example.com/users/1", "https://api.example.com/users/2", ...]
25
+
26
+ results = [None] * len(urls)
27
+ errors = []
28
+
29
+ with ThreadPoolExecutor(max_workers=10) as pool:
30
+ futures = {pool.submit(fetch, url): i for i, url in enumerate(urls)}
31
+ for f in as_completed(futures):
32
+ i = futures[f]
33
+ try:
34
+ results[i] = f.result()
35
+ except Exception as e:
36
+ errors.append((i, e))
37
+
38
+ # No rate limiting. No retry. No resume. And you still
39
+ # need to wire those yourself every time.
40
+ ```
41
+
42
+ **pyarallel:**
43
+
44
+ ```python
45
+ from pyarallel import parallel_map, RateLimit, Retry
46
+
47
+ result = parallel_map(
48
+ fetch, urls,
49
+ workers=10,
50
+ rate_limit=RateLimit(100, "minute"),
51
+ retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
52
+ )
53
+
54
+ for idx, val in result.successes():
55
+ save(val)
56
+ for idx, exc in result.failures():
57
+ log_error(idx, exc)
58
+ ```
59
+
60
+ Same thing, async:
61
+
62
+ ```python
63
+ import httpx
64
+ from pyarallel import async_parallel_map, RateLimit, Retry
65
+
66
+ async def fetch_async(url):
67
+ async with httpx.AsyncClient() as client:
68
+ return (await client.get(url, timeout=10)).json()
69
+
70
+ result = await async_parallel_map(
71
+ fetch_async, urls,
72
+ concurrency=10,
73
+ rate_limit=RateLimit(100, "minute"),
74
+ retry=Retry(attempts=3, on=(ConnectionError, TimeoutError)),
75
+ )
76
+ # Same result model — result.ok, result.successes(), result.failures()
77
+ ```
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ pip install pyarallel
83
+ ```
84
+
85
+ ## What You Get
86
+
87
+ - **Rate limiting** — token bucket with burst, per-second/minute/hour: `rate_limit=RateLimit(100, "minute", burst=20)`
88
+ - **Shared quota** — one `Limiter` instance across calls and functions when the budget belongs to an API key: `rate_limit=Limiter(RateLimit(100, "minute"))`
89
+ - **Retry with backoff** — per-item, exponential, jitter, exception filtering: `retry=Retry(attempts=3, on=(ConnectionError,))`
90
+ - **Server-driven backoff** — honor 429 + `Retry-After`: `retry=Retry(retry_if=..., wait_from=...)`; the wait also pauses the shared limiter so one throttled task slows the whole pool
91
+ - **Checkpoint/resume** — `checkpoint="run.ckpt"`: a crash at item 40,000 resumes instead of restarting from zero; `checkpoint_key=lambda u: u.id` keys rows by identity so evolving inputs keep completed work
92
+ - **Early abort** — `max_errors=10`: a dead API costs tens of calls, not thousands; unrun items are marked `Aborted`, partial results returned
93
+ - **One windowed engine** — every API (collected and streaming, sync and async) admits work through a bounded in-flight window: lazy input, generators never materialized, no batch barriers, a straggler never stalls the items behind it
94
+ - **Streaming** — `parallel_iter` / `async_parallel_iter`: `ordered=True` for input-order yields, per-item `attempts`/`duration`
95
+ - **Structured errors** — `ParallelResult` with `.ok`, `.ok_values()`, `.successes()`, `.failures()`, `.raise_on_failure()`, plus `.timed_out`/`.aborted` for how the run ended
96
+ - **Timeouts** — total wall-clock on sync *and* async (`timeout=30.0`), per-task in async (`task_timeout=5.0`)
97
+ - **Debug mode** — `sequential=True` runs inline: no pool, real stack traces, working breakpoints
98
+ - **Progress callbacks** — `on_progress=lambda done, total: print(f"{done}/{total}")` on collected and streaming APIs
99
+ - **Process executor** — CPU-bound work: `executor="process"`, with `worker_init=` and `max_tasks_per_worker=`
100
+ - **Contextvars propagation** — correlation IDs survive into thread workers
101
+ - **Decorator API** — `@parallel` / `@async_parallel` with `.map()`, `.starmap()`, `.stream()` — fully typed via `Unpack[TypedDict]`
102
+
103
+ ## Quick Start
104
+
105
+ ### Sync
106
+
107
+ ```python
108
+ import requests
109
+ from pyarallel import parallel_map, RateLimit, Retry
110
+
111
+ def fetch(url):
112
+ return requests.get(url, timeout=10).json()
113
+
114
+ # Fan out over a list, get ordered results
115
+ result = parallel_map(fetch, urls, workers=10)
116
+
117
+ # Rate-limited API calls with retry
118
+ def call_api(user_id):
119
+ return requests.get(f"https://api.example.com/users/{user_id}").json()
120
+
121
+ result = parallel_map(
122
+ call_api, user_ids,
123
+ workers=10,
124
+ rate_limit=RateLimit(100, "minute"),
125
+ retry=Retry(attempts=3, backoff=1.0, on=(ConnectionError, TimeoutError)),
126
+ )
127
+
128
+ # CPU-bound with processes
129
+ from PIL import Image
130
+
131
+ def resize_image(path):
132
+ img = Image.open(path)
133
+ img.thumbnail((800, 600))
134
+ img.save(path.replace(".png", "_thumb.png"))
135
+
136
+ result = parallel_map(resize_image, paths, executor="process")
137
+ ```
138
+
139
+ ### Async
140
+
141
+ ```python
142
+ import httpx
143
+ from pyarallel import async_parallel_map
144
+
145
+ async def fetch_async(url):
146
+ async with httpx.AsyncClient() as client:
147
+ return (await client.get(url, timeout=10)).json()
148
+
149
+ result = await async_parallel_map(
150
+ fetch_async, urls, concurrency=20, task_timeout=5.0,
151
+ )
152
+ ```
153
+
154
+ ### Decorator
155
+
156
+ Adds `.map()`, `.starmap()`, `.stream()` without changing the function:
157
+
158
+ ```python
159
+ from pyarallel import parallel, async_parallel, RateLimit
160
+
161
+ @parallel(workers=8, rate_limit=RateLimit(100, "minute"))
162
+ def fetch(url):
163
+ return requests.get(url).json()
164
+
165
+ fetch("http://example.com") # normal call — returns dict
166
+ fetch.map(urls) # parallel — returns ParallelResult
167
+ fetch.stream(urls, batch_size=500) # streaming — yields ItemResult
168
+
169
+ @async_parallel(concurrency=10)
170
+ async def fetch_async(url):
171
+ async with httpx.AsyncClient() as c:
172
+ return (await c.get(url)).json()
173
+
174
+ await fetch_async.map(urls) # async parallel
175
+ ```
176
+
177
+ ### Streaming — Constant Memory
178
+
179
+ For ETL, pipelines, or datasets too large to hold in memory:
180
+
181
+ ```python
182
+ from pyarallel import parallel_iter
183
+
184
+ def transform(row):
185
+ return {"id": row["id"], "name": row["name"].strip().title()}
186
+
187
+ for item in parallel_iter(transform, ten_million_rows, batch_size=1000):
188
+ if item.ok:
189
+ db.save(item.value)
190
+ else:
191
+ log_error(item.index, item.error)
192
+ ```
193
+
194
+ ### Error Handling
195
+
196
+ All errors collected, never silently swallowed:
197
+
198
+ ```python
199
+ def send_email(msg):
200
+ return smtp.send(msg["to"], msg["subject"], msg["body"])
201
+
202
+ result = parallel_map(send_email, messages)
203
+
204
+ if result.ok:
205
+ values = result.values() # list of all results, in order
206
+ else:
207
+ for idx, exc in result.failures():
208
+ log_error(idx, exc)
209
+ result.raise_on_failure() # or raise ExceptionGroup with all errors
210
+ ```
211
+
212
+ ## API Summary
213
+
214
+ | Function | Decorator | Returns | Use case |
215
+ |---|---|---|---|
216
+ | `parallel_map(fn, items)` | `.map(items)` | `ParallelResult` | Results fit in memory |
217
+ | `parallel_starmap(fn, items)` | `.starmap(items)` | `ParallelResult` | Multi-arg, fits in memory |
218
+ | `parallel_iter(fn, items)` | `.stream(items)` | `Iterator[ItemResult]` | Streaming, constant memory |
219
+
220
+ Async mirrors: `async_parallel_map`, `async_parallel_starmap`, `async_parallel_iter`
221
+
222
+ | Config | Example |
223
+ |---|---|
224
+ | `RateLimit(count, per, burst)` | `RateLimit(100, "minute", burst=20)` |
225
+ | `Limiter(rate_limit)` | shared budget: `Limiter(RateLimit(100, "minute"))` |
226
+ | `Retry(attempts, backoff, on, retry_if, wait_from)` | `Retry(attempts=3, on=(ConnectionError,))` |
227
+ | `checkpoint=` | resumable runs: `checkpoint="run.ckpt"` |
228
+
229
+ Works with instance methods and static methods via `@parallel` decorator — see [full docs](https://oneryalcin.github.io/pyarallel/).
230
+
231
+ ## Documentation
232
+
233
+ [Full docs](https://oneryalcin.github.io/pyarallel/) — API reference, advanced features, best practices.
234
+
235
+ ## License
236
+
237
+ MIT — see [LICENSE.md](LICENSE.md).