pyattacker 0.2.0__tar.gz → 0.3.1__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.2.0 → pyattacker-0.3.1}/.github/workflows/ci.yml +12 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/.github/workflows/release.yml +88 -9
- {pyattacker-0.2.0 → pyattacker-0.3.1}/CHANGELOG.md +229 -2
- {pyattacker-0.2.0 → pyattacker-0.3.1}/PKG-INFO +140 -13
- {pyattacker-0.2.0 → pyattacker-0.3.1}/README.md +139 -12
- pyattacker-0.3.1/README.zh-CN.md +423 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/benchmark.md +2 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/cli.md +102 -11
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/design.md +436 -19
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/reference.md +723 -34
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/releasing.md +32 -12
- {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/tutorial.md +480 -19
- pyattacker-0.3.1/docs/zh-CN/benchmark.md +208 -0
- pyattacker-0.3.1/docs/zh-CN/cli.md +289 -0
- pyattacker-0.3.1/docs/zh-CN/design.md +708 -0
- pyattacker-0.3.1/docs/zh-CN/reference.md +2185 -0
- pyattacker-0.3.1/docs/zh-CN/releasing.md +122 -0
- pyattacker-0.3.1/docs/zh-CN/tutorial.md +1865 -0
- pyattacker-0.3.1/examples/live_metrics.py +72 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/README.md +10 -0
- pyattacker-0.3.1/examples/llm_eval/README.zh-CN.md +102 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/README.md +2 -0
- pyattacker-0.3.1/examples/plugin_package/README.zh-CN.md +22 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/pyproject.toml +11 -2
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/__init__.py +23 -2
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/artifact.py +14 -1
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/cli.py +37 -1
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/declarative.py +17 -4
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/errors.py +51 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/export.py +6 -2
- pyattacker-0.3.1/src/pyattacker/handoff.py +499 -0
- pyattacker-0.3.1/src/pyattacker/history.py +146 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/merge.py +97 -11
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/monitor.py +31 -3
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/pipeline.py +73 -6
- pyattacker-0.3.1/src/pyattacker/reported_metrics.py +72 -0
- pyattacker-0.3.1/src/pyattacker/runner.py +2631 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/server.py +104 -27
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/__init__.py +18 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/base.py +226 -7
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/memory.py +313 -17
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/sqlite.py +568 -30
- pyattacker-0.3.1/src/pyattacker/store/visits.py +352 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/writebehind.py +117 -5
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/task.py +16 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/tasks/__init__.py +16 -0
- pyattacker-0.3.1/tests/test_backward.py +1459 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_cli.py +292 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_docs_examples.py +10 -5
- pyattacker-0.3.1/tests/test_docs_facts.py +126 -0
- pyattacker-0.3.1/tests/test_docs_i18n.py +222 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_export.py +245 -1
- pyattacker-0.3.1/tests/test_handoff.py +1480 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_monitor.py +1 -1
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_packaging.py +17 -2
- pyattacker-0.3.1/tests/test_reported_metrics.py +193 -0
- pyattacker-0.3.1/tests/test_resume_cursor.py +659 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_server.py +14 -10
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_store.py +102 -0
- pyattacker-0.3.1/tests/test_worker_liveness.py +425 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/uv.lock +1 -1
- pyattacker-0.2.0/assets/logo/icon.png +0 -0
- pyattacker-0.2.0/assets/logo/title.png +0 -0
- pyattacker-0.2.0/src/pyattacker/runner.py +0 -1441
- {pyattacker-0.2.0 → pyattacker-0.3.1}/.gitignore +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/LICENSE +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/data.jsonl +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/__init__.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/backend.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/demo.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/pipelines.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/__init__.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/algorithms.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/codecs.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/tasks.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pyproject.toml +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_tasks.yaml +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/qa_eval.yaml +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/quickstart.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/sharded.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/__main__.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/algorithm.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/backends.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/__init__.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/clock.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/harness.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/metrics.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/provider.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/report.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/scenario.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/plugins.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/resource.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/scheduler.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/shard.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/helpers.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_algorithms.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_artifact.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_backends.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_cli.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_clock.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_harness.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_provider.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_report.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_scenario.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_declarative.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_errors.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_lease_safety.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_m2.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_optional_yaml.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_pipeline.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_plugins.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_resume_identity.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_retry_policy.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_runner.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_scheduler.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_shard.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_subprocess_lifecycle.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_task_overrides.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_tasks.py +0 -0
- {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_tutorial.py +0 -0
|
@@ -55,6 +55,18 @@ jobs:
|
|
|
55
55
|
- name: Build sdist and wheel
|
|
56
56
|
run: uv build
|
|
57
57
|
|
|
58
|
+
- name: Check the sdist stays lean
|
|
59
|
+
# The cap release.yml also enforces before it uploads. Catching a 1.26 MB tarball in the pull
|
|
60
|
+
# request that added the bytes is cheaper than catching it on the tag: 0.2.0 published one.
|
|
61
|
+
run: |
|
|
62
|
+
size=$(stat -c %s dist/*.tar.gz)
|
|
63
|
+
if [ "$size" -gt 1048576 ]; then
|
|
64
|
+
echo "::error::the sdist is $size bytes, over the 1 MiB cap; see the include list in pyproject.toml"
|
|
65
|
+
tar tzvf dist/*.tar.gz | sort -k3 -n -r | head -5
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
echo "sdist is $size bytes, under the 1 MiB cap"
|
|
69
|
+
|
|
58
70
|
no-extra:
|
|
59
71
|
name: base install, no yaml extra
|
|
60
72
|
runs-on: ubuntu-latest
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
3
|
# Publishing is driven by a tag, so the artifact is always traceable to an exact commit.
|
|
4
|
-
# Pushing v0.2.0
|
|
5
|
-
#
|
|
6
|
-
# a
|
|
7
|
-
# published
|
|
4
|
+
# Pushing v0.2.0 verifies and builds the files, publishes them to TestPyPI, installs them back from
|
|
5
|
+
# TestPyPI by name, and only then uploads them to PyPI and attaches them to a GitHub Release. The
|
|
6
|
+
# scratch index is a stage rather than an optional rehearsal because it is the one place the
|
|
7
|
+
# published metadata is exercised the way a user exercises it — `pip install pyattacker==X.Y.Z` —
|
|
8
|
+
# and being scratch it costs nothing, while PyPI refuses a re-upload and a version, once published,
|
|
9
|
+
# can be deleted but never reused.
|
|
10
|
+
#
|
|
11
|
+
# `workflow_dispatch` stops before both uploads unless *Publish to TestPyPI* is checked, which is
|
|
12
|
+
# how you rehearse a release before spending a version number.
|
|
8
13
|
on:
|
|
9
14
|
push:
|
|
10
15
|
tags: ["v*"]
|
|
11
16
|
workflow_dispatch:
|
|
12
17
|
inputs:
|
|
13
18
|
publish_to_testpypi:
|
|
14
|
-
description: "
|
|
19
|
+
description: "Also upload to TestPyPI and install from it (otherwise the run only verifies and builds)"
|
|
15
20
|
type: boolean
|
|
16
21
|
default: false
|
|
17
22
|
|
|
@@ -115,6 +120,21 @@ jobs:
|
|
|
115
120
|
fi
|
|
116
121
|
echo "sdist contains no local tooling state"
|
|
117
122
|
|
|
123
|
+
# The size is a check of its own because 0.2.0 shipped 883 KB of logo PNG in a 1.26 MB sdist:
|
|
124
|
+
# `assets/` was in the include list, PNG does not compress, and nothing that unpacks an sdist
|
|
125
|
+
# needs branding. Source, tests and docs come to about 440 KB, so a 1 MiB cap leaves room for
|
|
126
|
+
# the project to grow while still failing on the next binary that does not belong in a source
|
|
127
|
+
# tree — which is cheaper to find here than in a release.
|
|
128
|
+
- name: Check the sdist stays lean
|
|
129
|
+
run: |
|
|
130
|
+
size=$(stat -c %s dist/*.tar.gz)
|
|
131
|
+
if [ "$size" -gt 1048576 ]; then
|
|
132
|
+
echo "::error::the sdist is $size bytes, over the 1 MiB cap; see the include list in pyproject.toml"
|
|
133
|
+
tar tzvf dist/*.tar.gz | sort -k3 -n -r | head -5
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
echo "sdist is $size bytes, under the 1 MiB cap"
|
|
137
|
+
|
|
118
138
|
- name: Check the sdist rebuilds and passes its own tests
|
|
119
139
|
run: |
|
|
120
140
|
mkdir -p /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist --strip-components=1
|
|
@@ -184,10 +204,13 @@ jobs:
|
|
|
184
204
|
if-no-files-found: error
|
|
185
205
|
|
|
186
206
|
# Publishing lives in its own job so the OIDC token is scoped to it alone: no test or build step
|
|
187
|
-
# ever holds a credential that can upload a release.
|
|
207
|
+
# ever holds a credential that can upload a release. That is also why the index is verified by the
|
|
208
|
+
# job below rather than by a step in this one.
|
|
188
209
|
testpypi:
|
|
189
210
|
name: publish to TestPyPI
|
|
190
|
-
|
|
211
|
+
# On a tag this is a stage of the release. On a dispatch it runs only when the input asks for it,
|
|
212
|
+
# so that a dry run stays a dry run.
|
|
213
|
+
if: startsWith(github.ref, 'refs/tags/v') || inputs.publish_to_testpypi
|
|
191
214
|
needs: build
|
|
192
215
|
runs-on: ubuntu-latest
|
|
193
216
|
environment: testpypi
|
|
@@ -206,7 +229,10 @@ jobs:
|
|
|
206
229
|
# caching here can only ever warn that the cache will never get invalidated.
|
|
207
230
|
enable-cache: false
|
|
208
231
|
|
|
209
|
-
# --check-url lets a re-run skip files already uploaded instead of failing the job
|
|
232
|
+
# --check-url lets a re-run skip files already uploaded instead of failing the job, which also
|
|
233
|
+
# makes a re-run of a rehearsed release idempotent: identical bytes are skipped and still
|
|
234
|
+
# verified below. Different bytes for a version TestPyPI already holds fail here on purpose —
|
|
235
|
+
# publishing those to PyPI would ship files no index has ever resolved.
|
|
210
236
|
- name: Publish
|
|
211
237
|
run: |
|
|
212
238
|
uv publish \
|
|
@@ -215,10 +241,63 @@ jobs:
|
|
|
215
241
|
--check-url https://test.pypi.org/simple/ \
|
|
216
242
|
dist/*
|
|
217
243
|
|
|
244
|
+
# The check the rehearsal used to leave to a human, and the reason TestPyPI is a stage at all:
|
|
245
|
+
# the files are installed the way a user installs them, by name from an index, with no credential
|
|
246
|
+
# in reach. `--index-url` is what makes the install come from TestPyPI rather than PyPI, and the
|
|
247
|
+
# version being released cannot be on PyPI yet — the upload that would put it there needs this job
|
|
248
|
+
# to pass — so the `--version` assertion proves the TestPyPI files are the ones that got installed.
|
|
249
|
+
# One index is enough because the base package declares no dependencies — `tests/test_packaging.py`
|
|
250
|
+
# enforces that — while TestPyPI's own PyYAML is frozen at 3.11, so a release that grew a runtime
|
|
251
|
+
# dependency fails here rather than publishing metadata no index could resolve. The wider
|
|
252
|
+
# `--extra-index-url` form, for the `yaml` extra and for that case, is in `docs/releasing.md`.
|
|
253
|
+
testpypi_check:
|
|
254
|
+
name: install from TestPyPI
|
|
255
|
+
needs: [build, testpypi]
|
|
256
|
+
runs-on: ubuntu-latest
|
|
257
|
+
steps:
|
|
258
|
+
- name: Install uv
|
|
259
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
260
|
+
with:
|
|
261
|
+
# No checkout in this job, so no lockfile to key a cache on — see the note in `testpypi`.
|
|
262
|
+
enable-cache: false
|
|
263
|
+
|
|
264
|
+
- name: Install the published files by name
|
|
265
|
+
env:
|
|
266
|
+
VERSION: ${{ needs.build.outputs.version }}
|
|
267
|
+
run: |
|
|
268
|
+
uv venv /tmp/index-check --python 3.11
|
|
269
|
+
# The simple index is a cached listing per CDN point of presence, so a file that was just
|
|
270
|
+
# uploaded can take a minute to show up in the one this job resolves through — measured at
|
|
271
|
+
# ~30-70s while this was written, on an upload uv itself had already resolved. A release
|
|
272
|
+
# waits that out rather than failing: ten attempts, 15 seconds apart.
|
|
273
|
+
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
|
274
|
+
if uv pip install --python /tmp/index-check/bin/python \
|
|
275
|
+
--index-url https://test.pypi.org/simple/ \
|
|
276
|
+
"pyattacker==$VERSION"; then
|
|
277
|
+
break
|
|
278
|
+
fi
|
|
279
|
+
if [ "$attempt" = 10 ]; then
|
|
280
|
+
echo "::error::pyattacker $VERSION is not installable from TestPyPI"
|
|
281
|
+
exit 1
|
|
282
|
+
fi
|
|
283
|
+
echo "attempt $attempt could not resolve pyattacker==$VERSION; retrying in 15s"
|
|
284
|
+
sleep 15
|
|
285
|
+
done
|
|
286
|
+
|
|
287
|
+
- name: The index copy is the version being released, and it runs
|
|
288
|
+
env:
|
|
289
|
+
VERSION: ${{ needs.build.outputs.version }}
|
|
290
|
+
run: |
|
|
291
|
+
/tmp/index-check/bin/pyattacker --version | grep -qx "pyattacker $VERSION"
|
|
292
|
+
/tmp/index-check/bin/pyattacker demo --pipelines 10 --store /tmp/index-check/demo.db
|
|
293
|
+
echo "TestPyPI serves pyattacker $VERSION and the installed CLI runs"
|
|
294
|
+
|
|
218
295
|
pypi:
|
|
219
296
|
name: publish to PyPI
|
|
220
297
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
221
|
-
|
|
298
|
+
# Gated on the index check, not just on the build: the files are published for real only after
|
|
299
|
+
# TestPyPI has served them to a clean environment and the CLI there reported this version.
|
|
300
|
+
needs: [build, testpypi_check]
|
|
222
301
|
runs-on: ubuntu-latest
|
|
223
302
|
environment: pypi
|
|
224
303
|
permissions:
|
|
@@ -4,7 +4,234 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.3.1] — 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **Application-reported live metrics.** `Runner.report_metric()` and `TaskContext.report_metric()`
|
|
12
|
+
publish latest values scoped to a run or pipeline. Applications can observe committed pipeline
|
|
13
|
+
completions with `on_pipeline_finished(runner, record, artifact)` and compute their own accuracy or
|
|
14
|
+
other cross-pipeline measures. SQLite and in-memory stores persist the reports; `/metrics`, the HTML
|
|
15
|
+
dashboard, and terminal `watch` display them. See `examples/live_metrics.py` for a restart-aware
|
|
16
|
+
accuracy monitor. Metric calculation remains application-owned.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* **Consistent monitoring scope.** With no explicit run ID, the dashboard, JSON endpoints and terminal
|
|
21
|
+
`watch` all select the latest started run. The dashboard uses that same ID for statistics, metrics,
|
|
22
|
+
pipelines and events, including when only pipeline-scoped values were reported. `run_id=all` requests
|
|
23
|
+
aggregate operational statistics without mixing in a single run's application metrics.
|
|
24
|
+
|
|
25
|
+
## [0.3.0] — 2026-09-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
* **Simplified Chinese documentation, kept in sync by CI.** `README.zh-CN.md` and `docs/zh-CN/` mirror the
|
|
30
|
+
README, every document in `docs/` (tutorial, reference, design, CLI, benchmark, backward traversal,
|
|
31
|
+
releasing) and the two example READMEs, and each pair carries a language switcher. The translation is
|
|
32
|
+
structural: fenced code blocks are byte-identical to the English ones — so the tests that execute the
|
|
33
|
+
English tutorial, README and CLI examples cover the Chinese documents too — and
|
|
34
|
+
`tests/test_docs_i18n.py` fails when a code block, a heading level, a language switcher or a relative
|
|
35
|
+
link drifts out of sync. Prose quality stays a review question; the machine checks the parts that rot.
|
|
36
|
+
|
|
37
|
+
* **Advanced backward traversal (opt-in, experimental):** `Handoff.rewind(target, value)` uses
|
|
38
|
+
author-selected state; `Handoff.retry_all()` replays immutable bound seed bytes. Separate declarations
|
|
39
|
+
require finite control budgets. Visit-aware atomic stores retain task/attempt/artifact occurrences,
|
|
40
|
+
effective lineage and exact pending inputs across rewind and resume. Revisited RNG includes `ctx.visit`;
|
|
41
|
+
visit-0 IDs and forward-only digests stay compatible. Optional `HistoryArtifact` payloads provide
|
|
42
|
+
detached snapshots, restoration, explicit pruning and registered-subclass codec round trips. Exports
|
|
43
|
+
and `/pipelines` expose visit-aware lineage. Restarting is explicit: `RunConfig.fresh_restart` /
|
|
44
|
+
`--fresh-restart` discards a checkpoint or traversal from the bound seed, resets the control budget and
|
|
45
|
+
keeps the append-only history, and a store that has committed its first revisit records a `base` → `visits-v1`
|
|
46
|
+
feature level (`store.feature_level()`, `StoreFeatureUnsupported`) so lineage-unaware writers are refused
|
|
47
|
+
rather than silently mutating the wrong occurrence. See
|
|
48
|
+
[reference → advanced: backward traversal](docs/reference.md#advanced-backward-traversal-rewind-retry-all-visits).
|
|
49
|
+
|
|
50
|
+
* **Advanced feature: handoffs — a task can skip ahead, on the record (opt-in, experimental).** A task may
|
|
51
|
+
return `Handoff.to(target, value)` to continue at a declared later station, or `Handoff.end(value)` to
|
|
52
|
+
finish the pipeline immediately. Until now the alternatives were to run the remaining stations anyway, to
|
|
53
|
+
fold the branch into one task with `fanout` (losing per-step records), or to raise — which records the
|
|
54
|
+
pipeline as *failed*, which is a lie. A handoff says what actually happened: this row skipped stations 3–5 and
|
|
55
|
+
continued at station 6, or finished here. It is also a **durable checkpoint**: the source task lands
|
|
56
|
+
`handed_off`, its attempt keeps `outcome="handed_off"`, the entry artifact is referenced or stored, a
|
|
57
|
+
`handoffs` ledger row records from/to and why, and the cursor moves — all in **one atomic commit**, so a
|
|
58
|
+
killed process resumes *at the target* with the entry state and never re-runs the source task.
|
|
59
|
+
`Observable changes:` a new append-only `handoffs` table (`store.handoffs(...)`, nested in the `pipelines`
|
|
60
|
+
export row, counted by `stats()["handoffs_total"]`, shown by `report`/`watch`/`/pipelines`); the attempt
|
|
61
|
+
outcome `handed_off`; handoff payload artifacts at `seq >= n_tasks`, so a payload can never overwrite a
|
|
62
|
+
task slot; the `pipeline.handoff` event; `Handoff` and `HandoffRecord` in the public API; and
|
|
63
|
+
`control={"edges": {...}}` on `pipeline(...)` plus the declarative `pipeline.control`. Edges are declared
|
|
64
|
+
rather than derived, so an undeclared or backward target is a fatal error instead of a silent jump, and
|
|
65
|
+
`fanout` rejects a directive returned by one of its branches. **A pipeline without a `control` block is
|
|
66
|
+
untouched**: no new rows, no counter changes, and a byte-identical `spec_digest` (pinned by a literal test),
|
|
67
|
+
so no stored checkpoint, pipeline id or shard assignment is invalidated. Handoffs need a store that can
|
|
68
|
+
commit them atomically; both built-in backends can, and a store that cannot is refused up front with a
|
|
69
|
+
`ConfigError` rather than silently writing a non-durable jump. Marked *experimental until 1.0*;
|
|
70
|
+
the forward declaration excludes backward targets; joins and cross-pipeline jumps are not included. See
|
|
71
|
+
[`docs/design.md` §4.8](docs/design.md#48-advanced-handoffs--declared-forward-jumps-opt-in-experimental),
|
|
72
|
+
the [API reference](docs/reference.md#advanced-handoffs-opt-in) and
|
|
73
|
+
[tutorial step 15](docs/tutorial.md#step-15--advanced-skipping-stations-handoffs).
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
* **Documentation drift that no test could see** (issue #60), and a lightweight check for the class of it.
|
|
78
|
+
The design document still opened with "Version: 0.1.0 (M0–M4 complete, M5 … in progress)" while
|
|
79
|
+
`pyproject.toml`, the changelog and the README had been on 0.2.0 since September, and M5 — forward
|
|
80
|
+
handoffs and backward traversal — had shipped; §8 still claimed "v1 only supports asyncio tasks" while the
|
|
81
|
+
first task of the tutorial is a plain `def` and the reference documents sync-task behaviour; the reference
|
|
82
|
+
still called the tutorial "fourteen runnable steps" after three more were added; and the monitoring docs
|
|
83
|
+
promised that `serve` "serves your artifact payloads" although no route returns one — what is actually
|
|
84
|
+
exposed is event `data` and stored `error_message` fields, which is a warning worth keeping, so the
|
|
85
|
+
sentence now names those instead of the wrong thing. All of it is mirrored in the Chinese documents.
|
|
86
|
+
`tests/test_docs_facts.py` now asserts the three claims that have a source of truth — the version header
|
|
87
|
+
against `pyproject.toml`, every "N runnable steps" claim against the number of `# tutorial/` blocks, and
|
|
88
|
+
every route the monitoring endpoint table advertises against the routes `StatsServer` answers — so the next
|
|
89
|
+
release cannot leave the header behind.
|
|
90
|
+
|
|
91
|
+
* **`merge_reports` no longer inflates its counters when it folds a duplicate** (issue #59). Rows were
|
|
92
|
+
always de-duplicated by `pipeline_id`, but `attempts_total` and `handoffs_total` were summed over the
|
|
93
|
+
sources — before de-duplication — so `pyattacker report a.db a.db`, or one pipeline living in two shards
|
|
94
|
+
after a shard-count change, doubled them while `pipelines.total` stayed put, contradicting the documented
|
|
95
|
+
promise that merging is idempotent. Both are now recomputed from the surviving rows (attempts from the
|
|
96
|
+
row's own `attempts_total`, handoffs from the nested ledger it carries), which is also what the module
|
|
97
|
+
docstring, `docs/design.md` §9 and `docs/reference.md` already claimed. The event log genuinely cannot be
|
|
98
|
+
re-derived — an event is not part of a pipeline row — so instead of pretending, it is renamed
|
|
99
|
+
`source_events_total` and documented as a raw per-source total; `summary()` prints it as
|
|
100
|
+
`source_events=`. Regression tests cover the same store passed twice (as two paths and as two objects),
|
|
101
|
+
one pipeline present in two shards, and the run-filtered handoff count; `tutorial.md` step 12 now shows
|
|
102
|
+
the identical `attempts=` on both sides of a duplicated merge (whose printed transcript had also drifted
|
|
103
|
+
from what the program actually prints). Counting from rows strengthens what a custom store must export,
|
|
104
|
+
so the two fields it reads are now explicit: `attempts_total` is required and a row without it raises a
|
|
105
|
+
`ConfigError` naming the row and source instead of quietly counting `0`, while a row that does not nest
|
|
106
|
+
the optional `handoffs` ledger is read through the store's own `handoffs()` capability when it has one
|
|
107
|
+
(a store with neither has no jumps, which is a true `0`).
|
|
108
|
+
|
|
109
|
+
* Backward-traversal follow-up: an explicit fresh start now resets a backward pipeline's control budget
|
|
110
|
+
while preserving durable visit counters and audit history (the counters were never reset — reusing visit
|
|
111
|
+
IDs would collide with historical occurrences), so a pipeline that failed *because* it spent its budget
|
|
112
|
+
can be restarted instead of replaying the same fatal error forever; the forward capability check is
|
|
113
|
+
applied to backward-enabled pipelines too, and `supports_visits` requires the v1
|
|
114
|
+
`commit_handoff`/`reset_pipeline` ledger capability the transition actually uses;
|
|
115
|
+
`MemoryStore.stats()["attempts_total"]` counts attempt rows for the run like `SqliteStore`, instead of
|
|
116
|
+
double-counting a resumed visit's consumed attempts.
|
|
117
|
+
|
|
118
|
+
* Backward-traversal review follow-up. `retry_succeeded=True` no longer restarts an unfinished backward
|
|
119
|
+
pipeline: it is an eligibility switch ("also admit succeeded pipelines"), and an unfinished pipeline still
|
|
120
|
+
owns a recoverable traversal, so replaying it from the seed would repeat external side effects the durable
|
|
121
|
+
checkpoint was about to continue. The operator escape hatch is the new `RunConfig.fresh_restart` /
|
|
122
|
+
`--fresh-restart` / `run.fresh_restart`: it discards the checkpoint or traversal, restarts from the bound
|
|
123
|
+
seed, resets the control budget and invalidates the previous ledger: append-only history (attempts,
|
|
124
|
+
events, handoffs, retained payloads) survives, and a backward pipeline additionally keeps its
|
|
125
|
+
visit-qualified occurrences and counters. It is also the documented recovery for a pipeline whose
|
|
126
|
+
traversal is gone, and it settles every task row it leaves in flight as `interrupted` instead of leaving
|
|
127
|
+
it `running` forever. Reopening a
|
|
128
|
+
`running` backward pipeline is now explicit: without `resume=True` the row is skipped
|
|
129
|
+
(`pipeline.skipped`, `reason="owned_by_another_run"`) instead of silently taking over a traversal another
|
|
130
|
+
run may still own; `resume=True` reclaims it and continues the exact durable visit. A fresh run no longer
|
|
131
|
+
reports `pipeline.checkpoint_missing` for its own seed (a `journal="summary"` store drops that payload by
|
|
132
|
+
design, which used to make every first execution look like a failed checkpoint). `control.retry_all` now
|
|
133
|
+
rejects aliases that resolve to the same source, matching `control.rewind`. A store that has committed a
|
|
134
|
+
revisit records it durably (`store_meta` feature level `visits-v1`, `store.feature_level()`), refuses to
|
|
135
|
+
open an unknown (newer) level with `StoreFeatureUnsupported`, and arms a writer guard so a lineage-unaware
|
|
136
|
+
writer fails loudly instead of mutating the wrong occurrence.
|
|
137
|
+
|
|
138
|
+
* Review follow-up: freeze resolved control topology; atomically reset current task/chain artifact state
|
|
139
|
+
on control-enabled seed replays while retaining history; expose handoff identity/watermark and active
|
|
140
|
+
versus historical API counts; treat unencodable handoff payloads as fatal; correct return-only
|
|
141
|
+
annotation documentation. Handoff-capable custom stores must also provide `reset_pipeline(record)`.
|
|
142
|
+
|
|
143
|
+
* Handoff follow-up: isolate historical ledger rows on whole-pipeline restarts with a durable
|
|
144
|
+
`handoff_floor` watermark (including old-store migration and read-only compatibility); roll back failed
|
|
145
|
+
SQLite handoff commits; select a single final artifact after reruns; reject duplicate source aliases;
|
|
146
|
+
preserve numeric source disambiguation in JSON/TOML/YAML and effective config; restrict the annotation
|
|
147
|
+
escape to return unions containing `Handoff`.
|
|
148
|
+
|
|
149
|
+
* **A worker that dies outside its own handlers no longer hangs the run.** Run completion was tracked purely
|
|
150
|
+
by pipeline counters (`pipelines_done` against `pipelines_admitted`), so a `BaseException` that is not
|
|
151
|
+
`CancelledError` — a custom subclass raised by a store or backend hook, for example — escaped the worker's
|
|
152
|
+
handler chain, ended the task, and left the run waiting forever on a condition the dead worker could no
|
|
153
|
+
longer satisfy: no error, no exit code, no terminal row and no event (only asyncio's "Task exception was
|
|
154
|
+
never retrieved" on stderr). Worker lifetime is now supervised separately from pipeline accounting, by a
|
|
155
|
+
done-callback on every worker task that also sees a death *after* the handler chain (queue or worker
|
|
156
|
+
housekeeping). The run is hard-stopped (`stop_reason == "worker_crashed"`) without manufacturing
|
|
157
|
+
`pipelines_done`; the pipeline the dead worker was holding is recorded `failed` — or `interrupted` when the
|
|
158
|
+
run was already winding down — with the escaping exception on the row, or created if the crash happened
|
|
159
|
+
before its row existed, while a pipeline that was already terminal keeps the state it earned; admission and
|
|
160
|
+
the shutdown sentinels hand items over through an abort-aware put, so a producer parked on the full queue of
|
|
161
|
+
a dead worker is released instead of hanging one step earlier; `runner.worker_crashed` records the pipeline,
|
|
162
|
+
the exception and its traceback; and `run_async` raises the new `WorkerCrashed` (original exception as
|
|
163
|
+
`__cause__`) after closing the run record as `interrupted`, with the CLI exiting 2 on a named error. If the
|
|
164
|
+
store cannot record the crash either, the existing `StoreUnavailable` fatal path is taken instead of
|
|
165
|
+
retrying a broken store. `KeyboardInterrupt`/`SystemExit` still tear the loop down, so a narrow guard inside
|
|
166
|
+
the worker records the row and the event before they continue on their way. Cancellation and the ordinary
|
|
167
|
+
internal-`Exception` recovery path are unchanged.
|
|
168
|
+
* **A pipeline whose cursor already reached the end can be resumed again.** The success path advanced the
|
|
169
|
+
checkpoint cursor and wrote the terminal state as two separate store calls, so a store failure in the
|
|
170
|
+
final write (or a kill between the two) could leave a `failed`/`interrupted` row with
|
|
171
|
+
`n_tasks_done == n_tasks_total`. Every later run then raised `IndexError: tuple index out of range` inside
|
|
172
|
+
`_open_pipeline`, surfaced only as `runner.internal_error`, while overwriting the row's original failure
|
|
173
|
+
with the framework's own crash. Such a row is now settled as completed: the last artifact is verified the
|
|
174
|
+
same way an ordinary resumed checkpoint is (present, payload kept, decodable), marked final, and the
|
|
175
|
+
pipeline is recorded `succeeded` without re-running a task, with `pipeline.terminal_repaired` carrying the
|
|
176
|
+
previous state, error and run. A cursor *past* the end is not a state the Runner can create: it is recorded
|
|
177
|
+
as `CorruptCheckpoint` and reported as `pipeline.corrupt_cursor` instead of being promoted to success, and
|
|
178
|
+
the stored value is left as the evidence; an artifact that is missing, payload-less or undecodable falls
|
|
179
|
+
back to the documented restart-from-zero rule (`pipeline.checkpoint_missing` /
|
|
180
|
+
`pipeline.checkpoint_unusable`). The repair never destroys the failure it is repairing before the outcome is
|
|
181
|
+
durable — `mark_final` runs first, the terminal transition (state, cursor, owning run, cleared failure
|
|
182
|
+
fields) is a single write where the store offers the optional `settle_pipeline` capability, and a repair
|
|
183
|
+
that fails in **either** step — marking the artifact final or settling the row — leaves the row untouched
|
|
184
|
+
(original failure and owning run included) and records `pipeline.terminal_repair_failed` with the phase,
|
|
185
|
+
so the next attempt still reports the original cause instead of the repair's own error. `mark_final` is
|
|
186
|
+
now contractually idempotent and is skipped when the artifact is already final. Because such a row keeps
|
|
187
|
+
its original owner, the failed repair is counted in the new `RunReport.repair_failures` (surfaced by
|
|
188
|
+
`summary()`, `to_dict()` and the CLI exit code) instead of silently leaving the run looking successful;
|
|
189
|
+
and on the two-write fallback for stores without `settle_pipeline`, a failed metadata cleanup after a
|
|
190
|
+
durable `succeeded` is reported as `pipeline.terminal_cleanup_failed` rather than as a failed repair. The final task now also marks its artifact final **before**
|
|
191
|
+
committing the terminal state and advances the cursor in that same call, so a crash in between re-runs the
|
|
192
|
+
final task (the documented at-least-once boundary) rather than leaving a `succeeded` pipeline whose final
|
|
193
|
+
artifact was never marked — a state no later run could repair, because a succeeded pipeline is skipped
|
|
194
|
+
forever.
|
|
195
|
+
|
|
196
|
+
### Changed
|
|
197
|
+
|
|
198
|
+
* **`MergedReport.events_total` is renamed `source_events_total`** (issue #59). The old name sat in the same
|
|
199
|
+
report as the de-duplicated counters without saying that it was the one number which was not de-duplicated;
|
|
200
|
+
the new name states the scope, `stats()` carries it under the same key, and `summary()` labels it
|
|
201
|
+
`source_events=`. Nothing else on `MergedReport` changed name. `MergedReport.events_total` survives as a
|
|
202
|
+
deprecated read-only alias (removed at 1.0) so an attribute read keeps working, but it is deliberately not a
|
|
203
|
+
second key in `stats()`: the point is that the JSON a report emits names one scope per number. Note that
|
|
204
|
+
`store.stats()["events_total"]` is untouched and is not the asymmetry it looks like — for a single store it
|
|
205
|
+
is the same measurement as the merged report's `source_events_total`, which the new test pins.
|
|
206
|
+
|
|
207
|
+
* **The backward-traversal guide is merged into the tutorial and the reference.** `docs/backward.md` was a
|
|
208
|
+
seventh document that a reader had to find before they could use the feature; its usage now lives where
|
|
209
|
+
the rest of the API does. [Tutorial step 16](docs/tutorial.md#step-16--advanced-regenerating-with-rewind-and-retry-all)
|
|
210
|
+
keeps the rewind/retry-all walkthrough and a new step 17 builds an optional `HistoryArtifact` payload,
|
|
211
|
+
while [reference → advanced: backward traversal](docs/reference.md#advanced-backward-traversal-rewind-retry-all-visits)
|
|
212
|
+
gains the `Handoff.rewind`/`Handoff.retry_all` signatures, the backward `control` keys, the visit and
|
|
213
|
+
occurrence model, the budget and recovery rules, and the inspection surface; `HistoryArtifact` is
|
|
214
|
+
documented next to the codecs it belongs to, and the store capability and compatibility/backup rules moved
|
|
215
|
+
into the Stores section. Both steps and the reference examples are marked **advanced**, stay opt-in and
|
|
216
|
+
experimental until 1.0, and are executed by the test suite (`# reference/<name>.py` joins the
|
|
217
|
+
executable-document markers). The Chinese tree mirrors all of it. No API changed.
|
|
218
|
+
|
|
219
|
+
* **The sdist no longer ships the branding images.** `assets/` was in the sdist include list, and PNG
|
|
220
|
+
barely compresses, so the two logo files were 883 KB of the 1.26 MB `pyattacker-0.2.0.tar.gz` that PyPI
|
|
221
|
+
serves — two thirds of the download for an archive whose purpose is to be rebuilt and tested. Nothing
|
|
222
|
+
that unpacks an sdist needs them, and the README loads its banner over an absolute
|
|
223
|
+
`raw.githubusercontent.com` URL, so the PyPI description is unaffected; the tarball is back to 439 KB.
|
|
224
|
+
Both `ci.yml` and `release.yml` now fail the build if the sdist grows past 1 MiB, and
|
|
225
|
+
`tests/test_packaging.py` fails if a README image ever points into the checkout again.
|
|
226
|
+
* **A release goes through TestPyPI before it reaches PyPI.** The scratch-index upload was a rehearsal a
|
|
227
|
+
maintainer had to opt into, and the `v0.2.0` tag skipped it — so the files PyPI received were the first copy
|
|
228
|
+
of that version any index had ever served. Every tag now publishes to TestPyPI, installs those files back by
|
|
229
|
+
name into a clean environment, asserts that the installed CLI reports the version being released, and only
|
|
230
|
+
then uploads to PyPI. The check is a job of its own with no OIDC token, so the job that holds a credential
|
|
231
|
+
that can publish does nothing else, and `pypi` now depends on it. A dispatched run still rehearses when
|
|
232
|
+
*Publish to TestPyPI* is checked and stops before both uploads otherwise. `docs/releasing.md` records the
|
|
233
|
+
consequence this makes load-bearing: a filename an index has seen can never be uploaded again, so a commit
|
|
234
|
+
after a rehearsal means a new version number, not a re-run.
|
|
8
235
|
|
|
9
236
|
## [0.2.0] — 2026-09-17
|
|
10
237
|
|
|
@@ -353,7 +580,7 @@ hard limit; asyncio tasks only (wrap blocking code with `asyncio.to_thread`); on
|
|
|
353
580
|
balance is statistical; a merged report is a union, not a sum; a pipeline is a linear chain; the HTTP endpoint
|
|
354
581
|
is unauthenticated.
|
|
355
582
|
|
|
356
|
-
[
|
|
583
|
+
[0.3.0]: https://github.com/Hazer-BJTU/pyattacker/releases/tag/v0.3.0
|
|
357
584
|
[0.2.0]: https://github.com/Hazer-BJTU/pyattacker/releases/tag/v0.2.0
|
|
358
585
|
[0.1.1]: https://github.com/Hazer-BJTU/pyattacker/releases/tag/v0.1.1
|
|
359
586
|
[0.1.0]: https://github.com/Hazer-BJTU/pyattacker/releases/tag/v0.1.0
|