pyattacker 0.1.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.
- pyattacker-0.1.0/.github/workflows/ci.yml +53 -0
- pyattacker-0.1.0/.gitignore +17 -0
- pyattacker-0.1.0/CHANGELOG.md +91 -0
- pyattacker-0.1.0/LICENSE +21 -0
- pyattacker-0.1.0/PKG-INFO +347 -0
- pyattacker-0.1.0/README.md +323 -0
- pyattacker-0.1.0/docs/cli.md +203 -0
- pyattacker-0.1.0/docs/design.md +639 -0
- pyattacker-0.1.0/docs/reference.md +1336 -0
- pyattacker-0.1.0/docs/tutorial.md +1641 -0
- pyattacker-0.1.0/examples/data.jsonl +5 -0
- pyattacker-0.1.0/examples/llm_eval/README.md +122 -0
- pyattacker-0.1.0/examples/llm_eval/__init__.py +4 -0
- pyattacker-0.1.0/examples/llm_eval/backend.py +264 -0
- pyattacker-0.1.0/examples/llm_eval/demo.py +193 -0
- pyattacker-0.1.0/examples/llm_eval/pipelines.py +347 -0
- pyattacker-0.1.0/examples/plugin_package/README.md +23 -0
- pyattacker-0.1.0/examples/plugin_package/pa_demo_plugin/__init__.py +10 -0
- pyattacker-0.1.0/examples/plugin_package/pa_demo_plugin/algorithms.py +34 -0
- pyattacker-0.1.0/examples/plugin_package/pa_demo_plugin/codecs.py +34 -0
- pyattacker-0.1.0/examples/plugin_package/pa_demo_plugin/tasks.py +35 -0
- pyattacker-0.1.0/examples/plugin_package/pyproject.toml +31 -0
- pyattacker-0.1.0/examples/plugin_tasks.yaml +33 -0
- pyattacker-0.1.0/examples/qa_eval.yaml +58 -0
- pyattacker-0.1.0/examples/quickstart.py +140 -0
- pyattacker-0.1.0/examples/sharded.py +135 -0
- pyattacker-0.1.0/pyproject.toml +82 -0
- pyattacker-0.1.0/src/pyattacker/__init__.py +198 -0
- pyattacker-0.1.0/src/pyattacker/__main__.py +12 -0
- pyattacker-0.1.0/src/pyattacker/algorithm.py +355 -0
- pyattacker-0.1.0/src/pyattacker/artifact.py +255 -0
- pyattacker-0.1.0/src/pyattacker/backends.py +228 -0
- pyattacker-0.1.0/src/pyattacker/cli.py +682 -0
- pyattacker-0.1.0/src/pyattacker/declarative.py +327 -0
- pyattacker-0.1.0/src/pyattacker/errors.py +223 -0
- pyattacker-0.1.0/src/pyattacker/export.py +291 -0
- pyattacker-0.1.0/src/pyattacker/merge.py +177 -0
- pyattacker-0.1.0/src/pyattacker/monitor.py +108 -0
- pyattacker-0.1.0/src/pyattacker/pipeline.py +241 -0
- pyattacker-0.1.0/src/pyattacker/plugins.py +244 -0
- pyattacker-0.1.0/src/pyattacker/resource.py +1018 -0
- pyattacker-0.1.0/src/pyattacker/runner.py +1406 -0
- pyattacker-0.1.0/src/pyattacker/scheduler.py +135 -0
- pyattacker-0.1.0/src/pyattacker/server.py +297 -0
- pyattacker-0.1.0/src/pyattacker/shard.py +107 -0
- pyattacker-0.1.0/src/pyattacker/store/__init__.py +32 -0
- pyattacker-0.1.0/src/pyattacker/store/base.py +351 -0
- pyattacker-0.1.0/src/pyattacker/store/memory.py +368 -0
- pyattacker-0.1.0/src/pyattacker/store/sqlite.py +734 -0
- pyattacker-0.1.0/src/pyattacker/store/writebehind.py +262 -0
- pyattacker-0.1.0/src/pyattacker/task.py +508 -0
- pyattacker-0.1.0/src/pyattacker/tasks/__init__.py +380 -0
- pyattacker-0.1.0/tests/helpers.py +50 -0
- pyattacker-0.1.0/tests/test_algorithms.py +591 -0
- pyattacker-0.1.0/tests/test_artifact.py +130 -0
- pyattacker-0.1.0/tests/test_backends.py +466 -0
- pyattacker-0.1.0/tests/test_cli.py +398 -0
- pyattacker-0.1.0/tests/test_declarative.py +500 -0
- pyattacker-0.1.0/tests/test_errors.py +193 -0
- pyattacker-0.1.0/tests/test_export.py +741 -0
- pyattacker-0.1.0/tests/test_lease_safety.py +313 -0
- pyattacker-0.1.0/tests/test_m2.py +232 -0
- pyattacker-0.1.0/tests/test_monitor.py +192 -0
- pyattacker-0.1.0/tests/test_packaging.py +92 -0
- pyattacker-0.1.0/tests/test_pipeline.py +197 -0
- pyattacker-0.1.0/tests/test_plugins.py +572 -0
- pyattacker-0.1.0/tests/test_runner.py +632 -0
- pyattacker-0.1.0/tests/test_scheduler.py +268 -0
- pyattacker-0.1.0/tests/test_server.py +451 -0
- pyattacker-0.1.0/tests/test_shard.py +302 -0
- pyattacker-0.1.0/tests/test_store.py +679 -0
- pyattacker-0.1.0/tests/test_tasks.py +130 -0
- pyattacker-0.1.0/tests/test_tutorial.py +95 -0
- pyattacker-0.1.0/uv.lock +167 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: python ${{ matrix.python-version }}
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
# The two versions declared in pyproject.toml classifiers.
|
|
21
|
+
python-version: ["3.11", "3.12"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v5
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: true
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: uv sync --locked
|
|
33
|
+
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: uv run ruff check
|
|
36
|
+
|
|
37
|
+
- name: Test
|
|
38
|
+
# The whole suite is offline; network access is never needed.
|
|
39
|
+
run: uv run pytest
|
|
40
|
+
|
|
41
|
+
- name: CLI smoke test
|
|
42
|
+
run: uv run pyattacker demo --pipelines 25 --store runs/demo.db
|
|
43
|
+
|
|
44
|
+
- name: Example smoke tests
|
|
45
|
+
# The examples are documentation, and documentation rots: the llm_eval demo also asserts
|
|
46
|
+
# its own claim about checkpoint granularity, so this catches recovery regressions.
|
|
47
|
+
run: |
|
|
48
|
+
uv run python examples/quickstart.py
|
|
49
|
+
uv run python examples/sharded.py
|
|
50
|
+
uv run python -m examples.llm_eval.demo
|
|
51
|
+
|
|
52
|
+
- name: Build sdist and wheel
|
|
53
|
+
run: uv build
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-09-17
|
|
8
|
+
|
|
9
|
+
First release. An artifact-centric async orchestration kernel: it runs many independent pipelines to
|
|
10
|
+
completion, resumably and observably, without touching the network itself.
|
|
11
|
+
|
|
12
|
+
### Core
|
|
13
|
+
|
|
14
|
+
* **Five-concept kernel** — artifact, task, pipeline, resource, algorithm. A task is a unary
|
|
15
|
+
`(artifact) -> artifact` function, sync or async; a pipeline is a linear chain of them and the unit of
|
|
16
|
+
completion; pipelines share nothing but resource pools.
|
|
17
|
+
* **Task-level checkpoints.** Every artifact is persisted the moment it is produced, so `resume` restarts at
|
|
18
|
+
the first task that produced nothing and re-sends nothing that already succeeded. The seed artifact is
|
|
19
|
+
persisted too, so recovery does not depend on the original dataset file.
|
|
20
|
+
* **Content-addressed identity.** `pipeline_key` is a digest of the task-chain fingerprint, the seed, and the
|
|
21
|
+
repeat index, which makes reruns idempotent, results reproducible, and `map(seeds, repeats=k)` (pass@k) free.
|
|
22
|
+
Task source digests are included, so changing task code is treated as a new pipeline rather than reusing a
|
|
23
|
+
stale checkpoint.
|
|
24
|
+
* **Lease safety contract.** Resources are only usable through a lease, and a task never holds one after it
|
|
25
|
+
ends: `async with ctx.acquire(...)` returns on exit, on exception, on cancellation and on timeout, and the
|
|
26
|
+
escape hatch is force-reclaimed with a `lease.leaked` event. Reclaim is a pure synchronous function, so
|
|
27
|
+
`CancelledError` cannot interrupt it. `strict_leases=True` turns a leak into a task failure.
|
|
28
|
+
* **Resource pools** with per-resource capacity, a `READY/DEGRADED/DEAD/REVOKED` state machine driven by
|
|
29
|
+
explicit `lease.report(...)`, quota accounting, publish/subscribe, and a signal bus.
|
|
30
|
+
* **Seven acquisition algorithms** — `wait`, `backoff`, `least_busy`, `failover`, `sticky`, `quota_aware`,
|
|
31
|
+
`immediate` — kept orthogonal to retry policy: the algorithm decides how to *get* a resource, retry decides
|
|
32
|
+
what to do *after* a failure.
|
|
33
|
+
* **Failure classification and retry.** `error_class_of` is a pure function mapping status codes and exception
|
|
34
|
+
types to `timeout`/`rate_limit`/`upstream`/`fatal`/`connection`/`invalid`/`unknown`; `Retrying` adds
|
|
35
|
+
exponential backoff with full jitter and a total-time budget. No retry by default. **Every retry decision is
|
|
36
|
+
persisted** — `{retry, reason, delay_s, error_class, attempt, max_attempts, retry_after}`.
|
|
37
|
+
|
|
38
|
+
### Scheduling and persistence
|
|
39
|
+
|
|
40
|
+
* **Backoff parks the pipeline instead of sleeping in a worker**, so `concurrency` means attempts in flight.
|
|
41
|
+
A parked pipeline is never mistaken for a dead one: the run waits for it, and a stop records it as resumable.
|
|
42
|
+
* **Targeted pool wakeups** — releasing a resource wakes only the waiters whose selector can use it.
|
|
43
|
+
* **Write-behind batching** for append-only facts (attempts, events), while state writes — pipelines, tasks,
|
|
44
|
+
artifacts — always go straight through. `SIGKILL` can cost the last batch of history, never a checkpoint.
|
|
45
|
+
`--no-write-behind` opts out.
|
|
46
|
+
* **SQLite store** (WAL, `synchronous=NORMAL`) across seven tables plus an in-memory store with identical
|
|
47
|
+
semantics. WAL's one-writer-many-readers model is what lets `watch`/`report`/`serve` run beside a live run.
|
|
48
|
+
* **Wait-time metrics** — `waits_total`, `wait_ms_avg`, `p50`, `p95`, `max`, and an `acquire.slow_wait` event
|
|
49
|
+
past `slow_wait_ms`. Suspected deadlocks emit `acquire.suspected_deadlock`.
|
|
50
|
+
|
|
51
|
+
### Scale, I/O and ecosystem
|
|
52
|
+
|
|
53
|
+
* **Deterministic sharding** — `--shard I/N` for one process doing its share, `--shards N` to spawn children
|
|
54
|
+
locally. Assignment is a pure function of the pipeline key, so the split is stable and resume-safe.
|
|
55
|
+
* **Merged reports** across shard stores: de-duplicated by `pipeline_id`, statistics recomputed, folded row
|
|
56
|
+
count reported.
|
|
57
|
+
* **Export** in five row shapes (`pipelines`, `tasks`, `attempts`, `events`, `artifacts`) and three formats
|
|
58
|
+
(`jsonl`, `json`, `csv`, with an `extra` column for late keys).
|
|
59
|
+
* **Entry-point plugins** for tasks, algorithms, codecs and stores. Built-ins resolve first, and a plugin that
|
|
60
|
+
raises on import is recorded rather than fatal — `pyattacker plugins` shows both.
|
|
61
|
+
* **Artifact backends** — `inline` (default), content-addressed `file://` spilling above a threshold with
|
|
62
|
+
transparent hydration on read, and `null`.
|
|
63
|
+
* **`fanout(...)`** for genuine branching inside one task, with group-level retry granularity as the stated price.
|
|
64
|
+
* **Read-only HTTP endpoint** — `pyattacker serve`, a dependency-free dashboard plus JSON. Loopback-only and
|
|
65
|
+
unauthenticated by design; it is a debug view, not a service.
|
|
66
|
+
* **CLI** — `run`, `resume`, `report`, `watch`, `export`, `serve`, `plugins`, `validate`, `demo`, with exit
|
|
67
|
+
codes `0`/`1`/`2`/`130`.
|
|
68
|
+
* **Declarative layer** — YAML/TOML/JSON describing composition and resources, with `${ENV}` expansion and
|
|
69
|
+
`--strict-env`.
|
|
70
|
+
|
|
71
|
+
### Documentation and testing
|
|
72
|
+
|
|
73
|
+
* A fourteen-step [tutorial](docs/tutorial.md) whose every code block is extracted and executed by the test
|
|
74
|
+
suite, so documentation that rots fails CI.
|
|
75
|
+
* A [design document](docs/design.md) stating the six invariants, the lease contract, the data model, and
|
|
76
|
+
twelve known tradeoffs up front.
|
|
77
|
+
* A [CLI reference](docs/cli.md) and four worked examples, including one that *measures* the checkpoint
|
|
78
|
+
granularity tradeoff rather than asserting it.
|
|
79
|
+
* The suite is offline and deterministic — all time goes through an injectable `Clock` — and runs in seconds.
|
|
80
|
+
CI covers Python 3.11 and 3.12, lint, the examples, and a build.
|
|
81
|
+
|
|
82
|
+
### Known limitations
|
|
83
|
+
|
|
84
|
+
Deliberate, and documented in [`docs/design.md`](docs/design.md) §8: stop conditions are best-effort;
|
|
85
|
+
write-behind can lose the tail of history (never a checkpoint); `journal=summary` and the `null` backend trade
|
|
86
|
+
away recovery granularity; resource health relies on explicit reporting; `quota_aware` is a preference, not a
|
|
87
|
+
hard limit; asyncio tasks only (wrap blocking code with `asyncio.to_thread`); one writer per database; shard
|
|
88
|
+
balance is statistical; a merged report is a union, not a sum; a pipeline is a linear chain; the HTTP endpoint
|
|
89
|
+
is unauthenticated.
|
|
90
|
+
|
|
91
|
+
[0.1.0]: https://github.com/Hazer-BJTU/pyattacker/releases/tag/v0.1.0
|
pyattacker-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 pyattacker contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyattacker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Artifact-centric, resumable async task orchestration: pipeline / task / resource pool.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Hazer-BJTU/pyattacker
|
|
6
|
+
Project-URL: Repository, https://github.com/Hazer-BJTU/pyattacker
|
|
7
|
+
Project-URL: Issues, https://github.com/Hazer-BJTU/pyattacker/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Hazer-BJTU/pyattacker/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/tutorial.md
|
|
10
|
+
Project-URL: Design, https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/design.md
|
|
11
|
+
Author: pyattacker contributors
|
|
12
|
+
License: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: artifact,async,evaluation,orchestration,pipeline,resource-pool
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyattacker
|
|
26
|
+
|
|
27
|
+
[](https://github.com/Hazer-BJTU/pyattacker/actions/workflows/ci.yml)
|
|
28
|
+
[](https://www.python.org/)
|
|
29
|
+
[](https://github.com/Hazer-BJTU/pyattacker/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
> Run tens of thousands of independent tasks to completion — resumably, observably, and without
|
|
32
|
+
> reimplementing endpoint pools, retries and "which rows already ran" for the fifth time.
|
|
33
|
+
|
|
34
|
+
## The situation this is for
|
|
35
|
+
|
|
36
|
+
Picture a long workflow test, a model benchmark, or any experiment made of many independent items.
|
|
37
|
+
Three hours in, one network request fails. The process dies — and you have no idea *which stage*
|
|
38
|
+
each item actually reached. Rerunning from scratch means paying for every request that already
|
|
39
|
+
succeeded, so you start writing a `results.jsonl` and a "skip what's already in there" check.
|
|
40
|
+
|
|
41
|
+
Then the provider starts rate-limiting you. A single API key serialises your parallel requests into
|
|
42
|
+
a queue, so you add a second key, a third, and now you need to decide which request goes where, what
|
|
43
|
+
happens when one endpoint starts returning 429s, and whether a failure means *retry* or *give up*.
|
|
44
|
+
Somewhere in there, `asyncio.Semaphore` stops being enough and you are writing a scheduler.
|
|
45
|
+
|
|
46
|
+
pyattacker is that scheduler, extracted and made boring:
|
|
47
|
+
|
|
48
|
+
* **a failure costs you one task, not the run** — every task's output is persisted the moment it is
|
|
49
|
+
produced, so `resume` restarts at the first task that produced nothing, and re-sends nothing that
|
|
50
|
+
already succeeded;
|
|
51
|
+
* **endpoints are a pool, not a global variable** — capacity, health, and quota per endpoint, leased
|
|
52
|
+
through `async with`, with seven policies for choosing which one to use and how to wait;
|
|
53
|
+
* **the record is queryable, not a log file** — every attempt, every retry decision (`{retry, reason,
|
|
54
|
+
delay_s, error_class}`), every intermediate artifact, in SQLite you can `SELECT` from while the run
|
|
55
|
+
is still going.
|
|
56
|
+
|
|
57
|
+
It **does not touch the network**: you write the openai/anthropic calls, it handles everything around
|
|
58
|
+
them. The core dependency list is the standard library plus PyYAML.
|
|
59
|
+
|
|
60
|
+
**New here?** The [tutorial](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/tutorial.md) goes from a five-line program to a sharded, resumable
|
|
61
|
+
model evaluation. Every snippet in it is executed by the test suite.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv add pyattacker # or: pip install pyattacker
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
From a clone:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/Hazer-BJTU/pyattacker && cd pyattacker
|
|
73
|
+
uv sync
|
|
74
|
+
uv run pyattacker demo # zero-config smoke test: 50 simulated pipelines, retries, a report
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires Python 3.11+.
|
|
78
|
+
|
|
79
|
+
## Core Model
|
|
80
|
+
|
|
81
|
+
Five concepts, and that is the whole vocabulary:
|
|
82
|
+
|
|
83
|
+
| Concept | Meaning | In one line |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| **artifact** | the persisted state of a task | content-addressed, **persisted as soon as it is produced** → checkpoint granularity = task |
|
|
86
|
+
| **task** | the smallest unit of scheduling | a unary `(artifact) -> artifact` function, sync or async |
|
|
87
|
+
| **pipeline** | the unit of completion | `fetch \| ask \| judge \| metrics` chained linearly, semantically independent of each other |
|
|
88
|
+
| **resource** | a leasable external capability | one endpoint / one key; once pooled, it can be published and subscribed to concurrency-safely |
|
|
89
|
+
| **algorithm** | the policy for acquiring resources | `wait`, `backoff`, `least_busy`, `failover`, `sticky`, `quota_aware`, `immediate` — orthogonal to "retry on failure" |
|
|
90
|
+
|
|
91
|
+
## 30-Second Quickstart (SDK)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pyattacker import Pool, Resource, Retrying, Runner, pipeline, task
|
|
95
|
+
|
|
96
|
+
@task("ask", resource="apis", algorithm="backoff",
|
|
97
|
+
retry=Retrying(max_attempts=4, base=0.5, cap=30.0), timeout_s=60)
|
|
98
|
+
async def ask(row: dict, ctx) -> dict:
|
|
99
|
+
async with ctx.acquire(model="gpt-4o") as lease: # returned on exit; returned on exception too
|
|
100
|
+
text = await lease.client.chat(row["q"]) # lease.client is built by resource.factory
|
|
101
|
+
lease.report(ok=True, usage={"tokens": 128}) # report health/quota back to the pool
|
|
102
|
+
return {"q": row["q"], "a": text}
|
|
103
|
+
|
|
104
|
+
pool = Pool("apis",
|
|
105
|
+
[Resource.create("llm", capacity=4, options={"model": "gpt-4o"},
|
|
106
|
+
factory=lambda res: MyClient(res.options)) for _ in range(8)],
|
|
107
|
+
algorithm="backoff")
|
|
108
|
+
|
|
109
|
+
template = pipeline("qa", ask)
|
|
110
|
+
|
|
111
|
+
with Runner(store="runs/qa.db", pools=[pool], concurrency=64) as runner:
|
|
112
|
+
report = runner.run(template.map(dataset_rows)) # a generator, streaming, memory O(concurrency)
|
|
113
|
+
print(report.summary())
|
|
114
|
+
report.export_jsonl("runs/qa.jsonl")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Want pass@k? `template.map(rows, repeats=3)` — the same seed expands into three independent pipelines.
|
|
118
|
+
|
|
119
|
+
## Lease Safety: The Framework's Hardest Guarantee
|
|
120
|
+
|
|
121
|
+
Resource leasing is the most critical point of interaction between your code and the framework, so:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
async with ctx.acquire(resource) as lease: # ← the only recommended form
|
|
125
|
+
for chunk in chunks: # repeatedly acquiring/releasing inside a loop is fine too
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
* An exception, a cancellation, a `timeout_s` timeout — **always returned**;
|
|
130
|
+
* Forgot to release through the escape hatch `await ctx.acquire_lease()`? It is **force-reclaimed** when the
|
|
131
|
+
task ends, and a `lease.leaked` event is recorded;
|
|
132
|
+
* Reclaim is a **pure synchronous function**, and `CancelledError` cannot interrupt it — this is why "a task
|
|
133
|
+
never holds a resource after it ends" holds;
|
|
134
|
+
* When you need something stricter, `strict_leases=True`: a leak immediately fails that task (`LeaseLeakError`).
|
|
135
|
+
|
|
136
|
+
## Scheduling That Keeps Its Promises
|
|
137
|
+
|
|
138
|
+
* **A retry backoff does not hold a worker.** When the policy wants another attempt, the pipeline is parked in
|
|
139
|
+
a delay queue and the worker immediately picks up other work. `concurrency` therefore means *attempts in
|
|
140
|
+
flight*, not *pipelines sitting out a 30-second backoff*.
|
|
141
|
+
* **A parked pipeline is never mistaken for a dead one.** The end of a run waits for parked pipelines too; a
|
|
142
|
+
stop records them as `interrupted` with their checkpoints intact, so `resume` picks them up.
|
|
143
|
+
* **Pool wakeups are targeted.** Releasing one resource wakes only the waiters whose selector can use it,
|
|
144
|
+
instead of every blocked pipeline.
|
|
145
|
+
* **History is batched, checkpoints are not.** Attempts and events are written in batches (size, interval,
|
|
146
|
+
heartbeat, and end-of-run), while artifacts and checkpoints are always committed immediately. `SIGKILL` can
|
|
147
|
+
cost you the last batch of history, never a checkpoint. `--no-write-behind` opts out.
|
|
148
|
+
* **Waiting is measured.** Pool stats report `waits_total`, `wait_ms_avg`, `p50`, `p95` and `max`, and a wait
|
|
149
|
+
beyond `slow_wait_ms` emits an `acquire.slow_wait` event you can alert on.
|
|
150
|
+
|
|
151
|
+
## Semantic Recovery
|
|
152
|
+
|
|
153
|
+
Every successful task persists its artifact and advances the checkpoint. On resume:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
runner.run(template.map(rows), resume=True) # or pyattacker resume -c config.yaml
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
* Already successful pipelines → skipped outright;
|
|
160
|
+
* Failed pipelines → continue from **the first task that produced no artifact**: **if task C died, only task C
|
|
161
|
+
reruns, and task B's request is not re-sent**;
|
|
162
|
+
* The seed artifact is persisted too → recovery **does not depend on the original dataset file**;
|
|
163
|
+
* Changed a task's source code (`spec_digest` includes source digests) → treated as a new pipeline, so old
|
|
164
|
+
results are not incorrectly reused.
|
|
165
|
+
|
|
166
|
+
## Running It Across Processes (Sharding)
|
|
167
|
+
|
|
168
|
+
SQLite takes one writer and the kernel is a single event loop, so scale means **processes with their own
|
|
169
|
+
stores**, joined afterwards. A pipeline's shard comes from its content-addressed key, so the same dataset always
|
|
170
|
+
splits the same way and `--resume` puts every pipeline back where it was:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# convenience: N children, then a merged report
|
|
174
|
+
uv run pyattacker run -c examples/qa_eval.yaml --shards 4 --jobs 4 --store runs/qa.db
|
|
175
|
+
# -> runs/qa.shard0of4.db … runs/qa.shard3of4.db
|
|
176
|
+
|
|
177
|
+
# or drive each shard yourself (cluster, scheduler, four terminals)
|
|
178
|
+
uv run pyattacker run -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
|
|
179
|
+
uv run pyattacker resume -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
|
|
180
|
+
|
|
181
|
+
# one coherent answer out of N files (de-duplicated, statistics recomputed)
|
|
182
|
+
uv run pyattacker report runs/qa.shard*of4.db
|
|
183
|
+
uv run pyattacker export runs/qa.shard*of4.db runs/all.jsonl
|
|
184
|
+
uv run pyattacker export runs/qa.shard*of4.db runs/tasks.csv --rows tasks --format csv
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`--rows` picks the shape: `pipelines` (nested, default), `tasks`, `attempts` (including each retry `decision`),
|
|
188
|
+
`events`, `artifacts`. `--format` picks `jsonl`, `json` or `csv`.
|
|
189
|
+
|
|
190
|
+
## Declarative (Simple Tasks)
|
|
191
|
+
|
|
192
|
+
The YAML describes **composition and resources**; the logic stays in Python (`use: my_pkg.tasks:ask`).
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
run: { store: runs/demo.db, concurrency: 8, label: demo }
|
|
196
|
+
pools:
|
|
197
|
+
apis:
|
|
198
|
+
kind: llm
|
|
199
|
+
algorithm: backoff
|
|
200
|
+
resources: [ { id: api-1, capacity: 4, options: { model: gpt-4o } } ]
|
|
201
|
+
pipeline:
|
|
202
|
+
name: qa
|
|
203
|
+
tasks:
|
|
204
|
+
- { use: pyattacker.tasks:echo }
|
|
205
|
+
- { use: pyattacker.tasks:simulate_llm, resource: apis, algorithm: backoff,
|
|
206
|
+
retry: { max_attempts: 3, on: [RetryableError, TimeoutError] } }
|
|
207
|
+
source: { kind: range, n: 100 }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
uv run pyattacker validate -c examples/qa_eval.yaml
|
|
212
|
+
uv run pyattacker run -c examples/qa_eval.yaml --progress
|
|
213
|
+
uv run pyattacker watch runs/demo.db # open another process to monitor it live
|
|
214
|
+
uv run pyattacker report runs/demo.db --errors 20
|
|
215
|
+
uv run pyattacker export runs/demo.db out.jsonl
|
|
216
|
+
uv run pyattacker serve runs/demo.db # HTTP dashboard + JSON endpoints
|
|
217
|
+
uv run pyattacker plugins # installed plugins
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Exit codes: `0` all succeeded / `1` some failed / `2` config error / `130` interrupted.
|
|
221
|
+
Every flag of every subcommand: [`docs/cli.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/cli.md).
|
|
222
|
+
|
|
223
|
+
## Records and Monitoring
|
|
224
|
+
|
|
225
|
+
The complete story of one pipeline = five tables queried by `pipeline_id`: state and checkpoint, the final
|
|
226
|
+
state of each task, the full attempt history (including **every retry decision**
|
|
227
|
+
`{retry, reason, delay_s, error_class}`), intermediate and final artifacts, and the structured event stream.
|
|
228
|
+
`pyattacker report/watch` consumes these facts directly.
|
|
229
|
+
|
|
230
|
+
## Extending It
|
|
231
|
+
|
|
232
|
+
**Plugins** are ordinary `importlib.metadata` entry points — install a package and its names become
|
|
233
|
+
usable from any config:
|
|
234
|
+
|
|
235
|
+
```toml
|
|
236
|
+
[project.entry-points."pyattacker.tasks"]
|
|
237
|
+
my_judge = "my_pkg.tasks:my_judge" # a TaskSpec, or a factory returning one
|
|
238
|
+
[project.entry-points."pyattacker.algorithms"]
|
|
239
|
+
my_algo = "my_pkg.algo:MyAlgorithm"
|
|
240
|
+
[project.entry-points."pyattacker.stores"]
|
|
241
|
+
s3 = "my_pkg.s3:open_store" # keyed by URI scheme: store = "s3://bucket/runs.db"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
uv run pyattacker plugins # what is installed, and what failed to load
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Built-ins resolve first (a plugin cannot shadow `echo`), and a plugin that raises on import is
|
|
249
|
+
recorded rather than fatal. A complete worked example: [`examples/plugin_package/`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/plugin_package/README.md).
|
|
250
|
+
|
|
251
|
+
**Large payloads** can live outside the database:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
uv run pyattacker run -c examples/qa_eval.yaml --artifact-backend file:///data/blobs
|
|
255
|
+
# or, in the config: artifact_backend = { kind = "file", root = "/data/blobs", min_bytes = 262144 }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Files are content-addressed, written atomically, and hydrated back on read — so a resumed run
|
|
259
|
+
reuses spilled checkpoints transparently. `null` keeps digests and drops bytes; `inline` (default)
|
|
260
|
+
keeps everything in the store.
|
|
261
|
+
|
|
262
|
+
**A zero-dependency monitoring endpoint**:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
uv run pyattacker serve runs/qa.db # http://127.0.0.1:8787
|
|
266
|
+
# / dashboard /stats /events /pipelines /resources /errors (JSON)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
It opens a fresh read-only connection per request, so it runs happily beside a live run. It has **no
|
|
270
|
+
authentication** and binds to loopback: it exposes your payloads, so treat it as a debug view and do not
|
|
271
|
+
put it on a public interface without your own proxy in front.
|
|
272
|
+
|
|
273
|
+
**Branching inside a step** — `fanout(a, b)` runs several tasks on the same input concurrently and
|
|
274
|
+
returns `{task_name: value}`. Retry granularity becomes the group, which is the honest price of not
|
|
275
|
+
turning pipelines into a DAG. The group is the only spec the Runner sees, so `resource`, `algorithm` and
|
|
276
|
+
`timeout_s` are inherited from the children when all of them agree (a `timeout_s` then bounds the whole
|
|
277
|
+
group).
|
|
278
|
+
|
|
279
|
+
## Documentation
|
|
280
|
+
|
|
281
|
+
| Document | What is in it |
|
|
282
|
+
|---|---|
|
|
283
|
+
| [`docs/tutorial.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/tutorial.md) | fourteen runnable steps, from "one task" to a sharded evaluation; each one executed by the test suite |
|
|
284
|
+
| [`docs/reference.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/reference.md) | every public class and function: signatures, parameters, examples |
|
|
285
|
+
| [`docs/cli.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/cli.md) | every subcommand, every flag, exit codes, config reference |
|
|
286
|
+
| [`docs/design.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/design.md) | conceptual model, the six invariants, the lease contract, data model, tradeoffs |
|
|
287
|
+
| [`CHANGELOG.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/CHANGELOG.md) | what changed, release by release |
|
|
288
|
+
|
|
289
|
+
## Examples
|
|
290
|
+
|
|
291
|
+
| Example | What it shows |
|
|
292
|
+
|---|---|
|
|
293
|
+
| [`examples/quickstart.py`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/quickstart.py) | the SDK in 60 lines: a custom client factory, retries, resume |
|
|
294
|
+
| [`examples/llm_eval/`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/llm_eval/README.md) | a complete evaluation — prepare → 2-turn model call → 3 judges → reduce, in **two pipeline shapes**, with the checkpoint-granularity tradeoff *measured* (grouped re-sent 2 judge requests that had already succeeded; split re-sent 0) |
|
|
295
|
+
| [`examples/sharded.py`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/sharded.py) | one dataset across N stores, then a merged report |
|
|
296
|
+
| [`examples/plugin_package/`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/plugin_package/README.md) | a real installable plugin: tasks, an algorithm, a codec |
|
|
297
|
+
| [`examples/qa_eval.yaml`](https://github.com/Hazer-BJTU/pyattacker/blob/main/examples/qa_eval.yaml) | the declarative path, end to end |
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
uv run python examples/quickstart.py
|
|
301
|
+
uv run python -m examples.llm_eval.demo
|
|
302
|
+
uv run python examples/sharded.py
|
|
303
|
+
uv run pyattacker run -c examples/qa_eval.yaml --limit 40
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Out of Scope
|
|
307
|
+
|
|
308
|
+
These are design decisions, not missing features:
|
|
309
|
+
|
|
310
|
+
* **Network requests** — you write the openai/anthropic protocols yourself. The kernel never opens a socket.
|
|
311
|
+
* **Semantic reduction** — accuracy, pass@k, F1 and any cross-pipeline aggregation. Export the artifacts and
|
|
312
|
+
compute it outside, or write a sink pipeline out of the primitives.
|
|
313
|
+
* **DAG orchestration** — a pipeline is a linear chain; branch inside a task with `fanout`.
|
|
314
|
+
* **A serving gateway** — the only HTTP surface is the read-only debug endpoint above.
|
|
315
|
+
* **Distributed scheduling** — scale out with `--shard`; multi-process is the ceiling.
|
|
316
|
+
|
|
317
|
+
Sections 1 and 11 of [`docs/design.md`](https://github.com/Hazer-BJTU/pyattacker/blob/main/docs/design.md) state the boundary precisely, and section 8 lists
|
|
318
|
+
every known tradeoff with its reason.
|
|
319
|
+
|
|
320
|
+
## Status
|
|
321
|
+
|
|
322
|
+
**0.1.0 — the first release.** Everything planned for it is implemented (M0–M4: the kernel, persistence and
|
|
323
|
+
task-level recovery, retries and error classification, the resource pool with 7 acquisition algorithms,
|
|
324
|
+
delayed continuations and write-behind batching, sharding and merged reports, five export shapes in three
|
|
325
|
+
formats, entry-point plugins, external artifact backends, the fan-out helper, and the HTTP monitoring
|
|
326
|
+
endpoint). The API is young: it follows semantic versioning from here, but expect refinement before 1.0.
|
|
327
|
+
|
|
328
|
+
Left for later: a distributed scheduler, Parquet export, blob garbage collection, and first-class
|
|
329
|
+
`Parallel`/`Gather` nodes.
|
|
330
|
+
|
|
331
|
+
## Development
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
uv sync # create the venv + install dependencies (the only core dependency is pyyaml)
|
|
335
|
+
uv run pytest # the whole suite: zero network, a few seconds
|
|
336
|
+
uv run ruff check # lint (configuration lives in pyproject.toml, with reasons for each exception)
|
|
337
|
+
uv run pyattacker demo # end-to-end smoke test
|
|
338
|
+
uv build # sdist + wheel
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Tests are offline and deterministic (time goes through an injectable `Clock`). The tutorial's code blocks are
|
|
342
|
+
extracted and executed by `tests/test_tutorial.py`, so documentation that rots fails CI.
|
|
343
|
+
|
|
344
|
+
## License
|
|
345
|
+
|
|
346
|
+
MIT — see [LICENSE](https://github.com/Hazer-BJTU/pyattacker/blob/main/LICENSE).
|
|
347
|
+
|