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.
- pyarallel-0.6.0/.github/workflows/ci.yml +31 -0
- {pyarallel-0.1.3 → pyarallel-0.6.0}/.gitignore +9 -2
- pyarallel-0.6.0/CHANGELOG.md +62 -0
- {pyarallel-0.1.3 → pyarallel-0.6.0}/CONTRIBUTING.md +4 -3
- pyarallel-0.6.0/Makefile +45 -0
- pyarallel-0.6.0/PKG-INFO +276 -0
- pyarallel-0.6.0/README.md +237 -0
- pyarallel-0.6.0/docs/api-reference/async.md +224 -0
- pyarallel-0.6.0/docs/api-reference/core.md +634 -0
- pyarallel-0.6.0/docs/api-reference/rate-limiting.md +87 -0
- {pyarallel-0.1.3 → pyarallel-0.6.0}/docs/development/CONTRIBUTING.md +4 -3
- pyarallel-0.6.0/docs/development/devx-principles.md +49 -0
- pyarallel-0.6.0/docs/development/plans/v0.5.0.md +652 -0
- pyarallel-0.6.0/docs/development/plans/v0.6-engine-unification.md +528 -0
- pyarallel-0.6.0/docs/development/plans/v0.6-ledger-refactor.md +273 -0
- pyarallel-0.6.0/docs/development/roadmap.md +244 -0
- pyarallel-0.6.0/docs/getting-started/installation.md +28 -0
- pyarallel-0.6.0/docs/getting-started/quickstart.md +186 -0
- pyarallel-0.6.0/docs/index.md +57 -0
- pyarallel-0.6.0/docs/user-guide/advanced-features.md +350 -0
- pyarallel-0.6.0/docs/user-guide/best-practices.md +232 -0
- pyarallel-0.6.0/docs/user-guide/real-world-patterns.md +288 -0
- pyarallel-0.6.0/examples/01_basic_usage.py +98 -0
- pyarallel-0.6.0/examples/02_api_calls_with_rate_limiting.py +128 -0
- pyarallel-0.6.0/examples/03_cpu_bound_processing.py +150 -0
- pyarallel-0.6.0/examples/04_batch_processing.py +134 -0
- pyarallel-0.6.0/examples/05_configuration.py +104 -0
- pyarallel-0.6.0/examples/06_resilient_api_jobs.py +104 -0
- pyarallel-0.6.0/examples/README.md +115 -0
- {pyarallel-0.1.3 → pyarallel-0.6.0}/mkdocs.yml +8 -5
- pyarallel-0.6.0/pyarallel/__init__.py +36 -0
- pyarallel-0.6.0/pyarallel/_procexec.py +54 -0
- pyarallel-0.6.0/pyarallel/_run.py +91 -0
- pyarallel-0.6.0/pyarallel/aio.py +607 -0
- pyarallel-0.6.0/pyarallel/checkpoint.py +355 -0
- pyarallel-0.6.0/pyarallel/core.py +1046 -0
- pyarallel-0.6.0/pyarallel/decorators.py +349 -0
- pyarallel-0.6.0/pyarallel/limiter.py +163 -0
- pyarallel-0.6.0/pyarallel/policies.py +133 -0
- pyarallel-0.6.0/pyarallel/py.typed +0 -0
- pyarallel-0.6.0/pyarallel/result.py +223 -0
- {pyarallel-0.1.3 → pyarallel-0.6.0}/pyproject.toml +20 -21
- pyarallel-0.6.0/tests/conftest.py +1 -0
- pyarallel-0.6.0/tests/smoke.py +261 -0
- pyarallel-0.6.0/tests/test_async.py +195 -0
- pyarallel-0.6.0/tests/test_batch.py +410 -0
- pyarallel-0.6.0/tests/test_checkpoint.py +618 -0
- pyarallel-0.6.0/tests/test_decorator.py +168 -0
- pyarallel-0.6.0/tests/test_limiter.py +213 -0
- pyarallel-0.6.0/tests/test_max_errors.py +553 -0
- pyarallel-0.6.0/tests/test_metadata.py +164 -0
- pyarallel-0.6.0/tests/test_parallel_map.py +183 -0
- pyarallel-0.6.0/tests/test_parity.py +494 -0
- pyarallel-0.6.0/tests/test_result.py +300 -0
- pyarallel-0.6.0/tests/test_retry.py +657 -0
- pyarallel-0.6.0/tests/test_starmap.py +232 -0
- pyarallel-0.6.0/tests/test_streaming.py +625 -0
- pyarallel-0.6.0/tests/typing_assertions.py +165 -0
- pyarallel-0.6.0/uv.lock +1215 -0
- pyarallel-0.1.3/Makefile +0 -36
- pyarallel-0.1.3/PKG-INFO +0 -490
- pyarallel-0.1.3/README.md +0 -451
- pyarallel-0.1.3/TASKS.md +0 -153
- pyarallel-0.1.3/docs/api-reference/configuration-api.md +0 -49
- pyarallel-0.1.3/docs/api-reference/decorators.md +0 -64
- pyarallel-0.1.3/docs/api-reference/rate-limiting.md +0 -60
- pyarallel-0.1.3/docs/development/roadmap.md +0 -78
- pyarallel-0.1.3/docs/getting-started/installation.md +0 -61
- pyarallel-0.1.3/docs/getting-started/quickstart.md +0 -94
- pyarallel-0.1.3/docs/index.md +0 -101
- pyarallel-0.1.3/docs/user-guide/advanced-features.md +0 -174
- pyarallel-0.1.3/docs/user-guide/best-practices.md +0 -207
- pyarallel-0.1.3/docs/user-guide/configuration.md +0 -150
- pyarallel-0.1.3/pyarallel/__init__.py +0 -10
- pyarallel-0.1.3/pyarallel/config.py +0 -154
- pyarallel-0.1.3/pyarallel/config_manager.py +0 -368
- pyarallel-0.1.3/pyarallel/core.py +0 -414
- pyarallel-0.1.3/pyarallel/env_config.py +0 -58
- pyarallel-0.1.3/sandbox/sandbox_test.py +0 -91
- pyarallel-0.1.3/setup.py +0 -46
- pyarallel-0.1.3/tests/conftest.py +0 -120
- pyarallel-0.1.3/tests/test_config_manager.py +0 -68
- pyarallel-0.1.3/tests/test_decorator_config.py +0 -127
- pyarallel-0.1.3/tests/test_env_config.py +0 -68
- pyarallel-0.1.3/tests/test_pyarallel.py +0 -184
- pyarallel-0.1.3/tests/test_runtime_config.py +0 -119
- pyarallel-0.1.3/uv.lock +0 -871
- {pyarallel-0.1.3 → pyarallel-0.6.0}/.python-version +0 -0
- {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
|
|
@@ -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
|
|
44
|
-
*
|
|
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
|
pyarallel-0.6.0/Makefile
ADDED
|
@@ -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/*
|
pyarallel-0.6.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pyarallel/) [](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
|
+
[](https://pypi.org/project/pyarallel/) [](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).
|