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.
Files changed (120) hide show
  1. {pyattacker-0.2.0 → pyattacker-0.3.1}/.github/workflows/ci.yml +12 -0
  2. {pyattacker-0.2.0 → pyattacker-0.3.1}/.github/workflows/release.yml +88 -9
  3. {pyattacker-0.2.0 → pyattacker-0.3.1}/CHANGELOG.md +229 -2
  4. {pyattacker-0.2.0 → pyattacker-0.3.1}/PKG-INFO +140 -13
  5. {pyattacker-0.2.0 → pyattacker-0.3.1}/README.md +139 -12
  6. pyattacker-0.3.1/README.zh-CN.md +423 -0
  7. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/benchmark.md +2 -0
  8. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/cli.md +102 -11
  9. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/design.md +436 -19
  10. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/reference.md +723 -34
  11. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/releasing.md +32 -12
  12. {pyattacker-0.2.0 → pyattacker-0.3.1}/docs/tutorial.md +480 -19
  13. pyattacker-0.3.1/docs/zh-CN/benchmark.md +208 -0
  14. pyattacker-0.3.1/docs/zh-CN/cli.md +289 -0
  15. pyattacker-0.3.1/docs/zh-CN/design.md +708 -0
  16. pyattacker-0.3.1/docs/zh-CN/reference.md +2185 -0
  17. pyattacker-0.3.1/docs/zh-CN/releasing.md +122 -0
  18. pyattacker-0.3.1/docs/zh-CN/tutorial.md +1865 -0
  19. pyattacker-0.3.1/examples/live_metrics.py +72 -0
  20. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/README.md +10 -0
  21. pyattacker-0.3.1/examples/llm_eval/README.zh-CN.md +102 -0
  22. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/README.md +2 -0
  23. pyattacker-0.3.1/examples/plugin_package/README.zh-CN.md +22 -0
  24. {pyattacker-0.2.0 → pyattacker-0.3.1}/pyproject.toml +11 -2
  25. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/__init__.py +23 -2
  26. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/artifact.py +14 -1
  27. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/cli.py +37 -1
  28. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/declarative.py +17 -4
  29. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/errors.py +51 -0
  30. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/export.py +6 -2
  31. pyattacker-0.3.1/src/pyattacker/handoff.py +499 -0
  32. pyattacker-0.3.1/src/pyattacker/history.py +146 -0
  33. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/merge.py +97 -11
  34. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/monitor.py +31 -3
  35. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/pipeline.py +73 -6
  36. pyattacker-0.3.1/src/pyattacker/reported_metrics.py +72 -0
  37. pyattacker-0.3.1/src/pyattacker/runner.py +2631 -0
  38. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/server.py +104 -27
  39. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/__init__.py +18 -0
  40. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/base.py +226 -7
  41. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/memory.py +313 -17
  42. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/sqlite.py +568 -30
  43. pyattacker-0.3.1/src/pyattacker/store/visits.py +352 -0
  44. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/store/writebehind.py +117 -5
  45. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/task.py +16 -0
  46. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/tasks/__init__.py +16 -0
  47. pyattacker-0.3.1/tests/test_backward.py +1459 -0
  48. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_cli.py +292 -0
  49. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_docs_examples.py +10 -5
  50. pyattacker-0.3.1/tests/test_docs_facts.py +126 -0
  51. pyattacker-0.3.1/tests/test_docs_i18n.py +222 -0
  52. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_export.py +245 -1
  53. pyattacker-0.3.1/tests/test_handoff.py +1480 -0
  54. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_monitor.py +1 -1
  55. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_packaging.py +17 -2
  56. pyattacker-0.3.1/tests/test_reported_metrics.py +193 -0
  57. pyattacker-0.3.1/tests/test_resume_cursor.py +659 -0
  58. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_server.py +14 -10
  59. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_store.py +102 -0
  60. pyattacker-0.3.1/tests/test_worker_liveness.py +425 -0
  61. {pyattacker-0.2.0 → pyattacker-0.3.1}/uv.lock +1 -1
  62. pyattacker-0.2.0/assets/logo/icon.png +0 -0
  63. pyattacker-0.2.0/assets/logo/title.png +0 -0
  64. pyattacker-0.2.0/src/pyattacker/runner.py +0 -1441
  65. {pyattacker-0.2.0 → pyattacker-0.3.1}/.gitignore +0 -0
  66. {pyattacker-0.2.0 → pyattacker-0.3.1}/LICENSE +0 -0
  67. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/data.jsonl +0 -0
  68. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/__init__.py +0 -0
  69. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/backend.py +0 -0
  70. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/demo.py +0 -0
  71. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/llm_eval/pipelines.py +0 -0
  72. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/__init__.py +0 -0
  73. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/algorithms.py +0 -0
  74. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/codecs.py +0 -0
  75. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pa_demo_plugin/tasks.py +0 -0
  76. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_package/pyproject.toml +0 -0
  77. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/plugin_tasks.yaml +0 -0
  78. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/qa_eval.yaml +0 -0
  79. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/quickstart.py +0 -0
  80. {pyattacker-0.2.0 → pyattacker-0.3.1}/examples/sharded.py +0 -0
  81. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/__main__.py +0 -0
  82. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/algorithm.py +0 -0
  83. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/backends.py +0 -0
  84. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/__init__.py +0 -0
  85. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/clock.py +0 -0
  86. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/harness.py +0 -0
  87. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/metrics.py +0 -0
  88. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/provider.py +0 -0
  89. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/report.py +0 -0
  90. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/benchmark/scenario.py +0 -0
  91. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/plugins.py +0 -0
  92. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/resource.py +0 -0
  93. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/scheduler.py +0 -0
  94. {pyattacker-0.2.0 → pyattacker-0.3.1}/src/pyattacker/shard.py +0 -0
  95. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/helpers.py +0 -0
  96. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_algorithms.py +0 -0
  97. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_artifact.py +0 -0
  98. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_backends.py +0 -0
  99. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_cli.py +0 -0
  100. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_clock.py +0 -0
  101. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_harness.py +0 -0
  102. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_provider.py +0 -0
  103. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_report.py +0 -0
  104. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_benchmark_scenario.py +0 -0
  105. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_declarative.py +0 -0
  106. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_errors.py +0 -0
  107. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_lease_safety.py +0 -0
  108. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_m2.py +0 -0
  109. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_optional_yaml.py +0 -0
  110. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_pipeline.py +0 -0
  111. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_plugins.py +0 -0
  112. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_resume_identity.py +0 -0
  113. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_retry_policy.py +0 -0
  114. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_runner.py +0 -0
  115. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_scheduler.py +0 -0
  116. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_shard.py +0 -0
  117. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_subprocess_lifecycle.py +0 -0
  118. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_task_overrides.py +0 -0
  119. {pyattacker-0.2.0 → pyattacker-0.3.1}/tests/test_tasks.py +0 -0
  120. {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 builds, verifies and publishes to PyPI, then attaches the files to a GitHub
5
- # Release. `workflow_dispatch` runs everything except the PyPI upload, which is how you rehearse
6
- # a release without spending a version number: PyPI refuses a re-upload, and a version, once
7
- # published, can be deleted but never reused.
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: "Publish to TestPyPI (a dry run needs no version number)"
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
- if: github.event_name == 'workflow_dispatch' && inputs.publish_to_testpypi
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
- needs: build
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
- ## [Unreleased]
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
- [Unreleased]: https://github.com/Hazer-BJTU/pyattacker/compare/v0.2.0...HEAD
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