phaseprobe 0.2.1__tar.gz → 0.3.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.
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CHANGELOG.md +12 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CITATION.cff +1 -2
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/PKG-INFO +20 -1
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/README.md +19 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/pyproject.toml +1 -1
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/__init__.py +1 -1
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/cli.py +28 -1
- phaseprobe-0.3.0/src/phaseprobe/generate.py +377 -0
- phaseprobe-0.3.0/tests/test_artifacts_replay.py +229 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_cli.py +27 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/uv.lock +1 -1
- phaseprobe-0.2.1/src/phaseprobe/generate.py +0 -88
- phaseprobe-0.2.1/tests/test_artifacts_replay.py +0 -99
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_accessibility.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_pendulum.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_schema.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/ci.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/release.yml +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.gitignore +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/AGENTS.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/ARCHITECTURE.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CONTRIBUTING.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/LICENSE +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/LIMITATIONS.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/PRIOR_ART.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/ROADMAP.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/SCIENTIFIC_METHODS.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/SECURITY.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo-session.txt +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo-static.png +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo.gif +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo-session.txt +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo-static.png +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo.gif +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/social-preview.jpg +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/docs/SCIPY_SOLVE_IVP_AUDIT.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/README.md +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/logistic-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/logistic-scan.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/lorenz-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/lorenz-perturb.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/predator-prey-check.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/predator-prey-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/toggle-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/toggle-perturb.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/lorenz-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/lorenz.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/predator-prey-coarse.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/predator-prey.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/report.html +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/audit_package.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/check_links.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/generate_demo.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/generate_scipy_demo.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/hygiene.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/installed_smoke.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/verify_artifacts.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/__main__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/__init__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/loader.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/scipy.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/api.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/artifacts.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/config.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/__init__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/__init__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/logistic-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/logistic-scan.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/lorenz-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/lorenz-perturb.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/predator-prey-check.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/predator-prey-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-lorenz-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-lorenz.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-predator-prey-coarse.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-predator-prey.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/toggle-negative.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/toggle-perturb.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/engine.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/errors.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/examples/__init__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/examples/scipy_models.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/__init__.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/_common.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/logistic.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/lorenz.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/predator_prey.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/toggle.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/py.typed +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/replay.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/reporting.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/types.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/conftest.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/logistic_map-replay.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/lorenz_scipy-replay.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/predator_prey_scipy-replay.json +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_logistic_map_transition.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_lorenz_scipy_transition.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_predator_prey_scipy_transition.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_config.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_engine.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_optional_dependency.py +0 -0
- {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_scipy_adapter.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes are documented here. PhaseProbe follows semantic versioning.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
- Added bounded offline `verify-evidence` validation for generated pytest evidence, replay
|
|
8
|
+
decisions, artifact hashes, safe relative paths, and the fixed regression template.
|
|
9
|
+
- The unreleased package identity is now `0.3.0` for the new generated-evidence contract.
|
|
10
|
+
- `generate-test` now writes a path-portable claim/decision evidence record that binds the
|
|
11
|
+
validated replay verdict to SHA-256 hashes of the copied fixture and generated pytest, the
|
|
12
|
+
exact reproduction command, PhaseProbe attribution, and explicit scientific limitations.
|
|
13
|
+
- Identical generated evidence is idempotent; conflicting evidence is rejected without overwrite.
|
|
14
|
+
- Invariant-only fixtures now use the narrower `simulation-replay-regression` claim kind, and the
|
|
15
|
+
unsigned evidence-integrity boundary is explicit.
|
|
16
|
+
|
|
5
17
|
## 0.2.1 — 2026-08-31
|
|
6
18
|
|
|
7
19
|
- Fixed Issue #4: all four SciPy quick-start configurations now ship inside the importable
|
|
@@ -4,8 +4,7 @@ title: "PhaseProbe"
|
|
|
4
4
|
type: software
|
|
5
5
|
authors:
|
|
6
6
|
- name: "Ali"
|
|
7
|
-
version: 0.
|
|
8
|
-
date-released: 2026-08-31
|
|
7
|
+
version: 0.3.0
|
|
9
8
|
url: "https://github.com/aliengineering-byte/phaseprobe"
|
|
10
9
|
repository-code: "https://github.com/aliengineering-byte/phaseprobe"
|
|
11
10
|
license: Apache-2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: phaseprobe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Find reproducible qualitative simulation transitions and turn them into regression tests.
|
|
5
5
|
Project-URL: Documentation, https://github.com/aliengineering-byte/phaseprobe#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/aliengineering-byte/phaseprobe/issues
|
|
@@ -79,6 +79,7 @@ pip install phaseprobe pytest
|
|
|
79
79
|
phaseprobe scan --example logistic
|
|
80
80
|
phaseprobe replay .phaseprobe/runs/<run-id>/replay.json
|
|
81
81
|
phaseprobe generate-test .phaseprobe/runs/<run-id>/replay.json
|
|
82
|
+
phaseprobe verify-evidence tests/generated/logistic_map-pytest-evidence.json
|
|
82
83
|
python -m pytest -q tests/generated
|
|
83
84
|
phaseprobe report .phaseprobe/runs/<run-id>
|
|
84
85
|
```
|
|
@@ -172,6 +173,7 @@ does not import it. See [the audited contract](docs/SCIPY_SOLVE_IVP_AUDIT.md),
|
|
|
172
173
|
| `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails |
|
|
173
174
|
| `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Declared `exact` or `tolerance` comparison passes |
|
|
174
175
|
| `generate-test` | Validate and copy a fixture into a non-extensible pytest template without conflicting overwrites | Executable test under `tests/generated/` |
|
|
176
|
+
| `verify-evidence` | Re-derive claims/replay decisions and validate bounded strict JSON, artifact hashes, paths, and the fixed template offline | Stable verified or invalid-input exit |
|
|
175
177
|
| `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files |
|
|
176
178
|
|
|
177
179
|
Common options:
|
|
@@ -218,6 +220,22 @@ manifest.json sizes and SHA-256 hashes
|
|
|
218
220
|
|
|
219
221
|
Model names used for generated test paths are sanitized. The generated source comes from a fixed template; configuration strings never become executable Python.
|
|
220
222
|
|
|
223
|
+
`generate-test` validates the replay before writing anything. Alongside the fixed pytest template
|
|
224
|
+
and copied fixture, it writes `<model>-pytest-evidence.json`: a path-portable claim/decision record
|
|
225
|
+
containing repository/version attribution, the declared replay comparisons, SHA-256 hashes for
|
|
226
|
+
both executable artifacts, the exact pytest command, and explicit scientific limitations.
|
|
227
|
+
Run `phaseprobe verify-evidence <evidence.json>` before pytest to validate the strict schema,
|
|
228
|
+
recompute the hashes, re-execute the replay policy, derive the claim/decision fields, and confirm
|
|
229
|
+
that the regression still matches PhaseProbe's fixed template. Validation is offline, bounded to a
|
|
230
|
+
1 MiB evidence document and 32 MiB per generated artifact, rejects duplicate keys and unsafe paths,
|
|
231
|
+
and exits `2` for invalid input.
|
|
232
|
+
The record is unsigned: consumers must recompute the two artifact hashes to detect a recomputed or
|
|
233
|
+
substituted record. It is portable integrity metadata, not an authentication signature.
|
|
234
|
+
|
|
235
|
+
If `generate-test` reports a conflict, it has left the existing output untouched. An identical
|
|
236
|
+
artifact can be generated again safely; for different content, inspect the existing files and
|
|
237
|
+
choose a fresh output directory (or remove them only after deciding they are no longer needed).
|
|
238
|
+
|
|
221
239
|
## What PhaseProbe adds—and what it does not
|
|
222
240
|
|
|
223
241
|
| existing category | established strength | PhaseProbe’s narrower job |
|
|
@@ -299,6 +317,7 @@ python scripts/hygiene.py
|
|
|
299
317
|
```
|
|
300
318
|
|
|
301
319
|
See [CONTRIBUTING.md](CONTRIBUTING.md) and the concrete good-first-issue templates in `.github/ISSUE_TEMPLATE/`.
|
|
320
|
+
Report a reproducible defect with the [bug form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=bug_report.yml), or propose a bounded adapter or workflow with the [feature form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=feature_request.yml). Please do not include secrets or private model data.
|
|
302
321
|
|
|
303
322
|
## License and citation
|
|
304
323
|
|
|
@@ -41,6 +41,7 @@ pip install phaseprobe pytest
|
|
|
41
41
|
phaseprobe scan --example logistic
|
|
42
42
|
phaseprobe replay .phaseprobe/runs/<run-id>/replay.json
|
|
43
43
|
phaseprobe generate-test .phaseprobe/runs/<run-id>/replay.json
|
|
44
|
+
phaseprobe verify-evidence tests/generated/logistic_map-pytest-evidence.json
|
|
44
45
|
python -m pytest -q tests/generated
|
|
45
46
|
phaseprobe report .phaseprobe/runs/<run-id>
|
|
46
47
|
```
|
|
@@ -134,6 +135,7 @@ does not import it. See [the audited contract](docs/SCIPY_SOLVE_IVP_AUDIT.md),
|
|
|
134
135
|
| `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails |
|
|
135
136
|
| `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Declared `exact` or `tolerance` comparison passes |
|
|
136
137
|
| `generate-test` | Validate and copy a fixture into a non-extensible pytest template without conflicting overwrites | Executable test under `tests/generated/` |
|
|
138
|
+
| `verify-evidence` | Re-derive claims/replay decisions and validate bounded strict JSON, artifact hashes, paths, and the fixed template offline | Stable verified or invalid-input exit |
|
|
137
139
|
| `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files |
|
|
138
140
|
|
|
139
141
|
Common options:
|
|
@@ -180,6 +182,22 @@ manifest.json sizes and SHA-256 hashes
|
|
|
180
182
|
|
|
181
183
|
Model names used for generated test paths are sanitized. The generated source comes from a fixed template; configuration strings never become executable Python.
|
|
182
184
|
|
|
185
|
+
`generate-test` validates the replay before writing anything. Alongside the fixed pytest template
|
|
186
|
+
and copied fixture, it writes `<model>-pytest-evidence.json`: a path-portable claim/decision record
|
|
187
|
+
containing repository/version attribution, the declared replay comparisons, SHA-256 hashes for
|
|
188
|
+
both executable artifacts, the exact pytest command, and explicit scientific limitations.
|
|
189
|
+
Run `phaseprobe verify-evidence <evidence.json>` before pytest to validate the strict schema,
|
|
190
|
+
recompute the hashes, re-execute the replay policy, derive the claim/decision fields, and confirm
|
|
191
|
+
that the regression still matches PhaseProbe's fixed template. Validation is offline, bounded to a
|
|
192
|
+
1 MiB evidence document and 32 MiB per generated artifact, rejects duplicate keys and unsafe paths,
|
|
193
|
+
and exits `2` for invalid input.
|
|
194
|
+
The record is unsigned: consumers must recompute the two artifact hashes to detect a recomputed or
|
|
195
|
+
substituted record. It is portable integrity metadata, not an authentication signature.
|
|
196
|
+
|
|
197
|
+
If `generate-test` reports a conflict, it has left the existing output untouched. An identical
|
|
198
|
+
artifact can be generated again safely; for different content, inspect the existing files and
|
|
199
|
+
choose a fresh output directory (or remove them only after deciding they are no longer needed).
|
|
200
|
+
|
|
183
201
|
## What PhaseProbe adds—and what it does not
|
|
184
202
|
|
|
185
203
|
| existing category | established strength | PhaseProbe’s narrower job |
|
|
@@ -261,6 +279,7 @@ python scripts/hygiene.py
|
|
|
261
279
|
```
|
|
262
280
|
|
|
263
281
|
See [CONTRIBUTING.md](CONTRIBUTING.md) and the concrete good-first-issue templates in `.github/ISSUE_TEMPLATE/`.
|
|
282
|
+
Report a reproducible defect with the [bug form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=bug_report.yml), or propose a bounded adapter or workflow with the [feature form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=feature_request.yml). Please do not include secrets or private model data.
|
|
264
283
|
|
|
265
284
|
## License and citation
|
|
266
285
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phaseprobe"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Find reproducible qualitative simulation transitions and turn them into regression tests."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -19,7 +19,7 @@ from phaseprobe.errors import (
|
|
|
19
19
|
NumericalFailure,
|
|
20
20
|
PhaseProbeError,
|
|
21
21
|
)
|
|
22
|
-
from phaseprobe.generate import generate_regression_test
|
|
22
|
+
from phaseprobe.generate import generate_regression_test, verify_generated_evidence
|
|
23
23
|
from phaseprobe.replay import verify_replay
|
|
24
24
|
from phaseprobe.reporting import json_report, regenerate_reports, terminal_report
|
|
25
25
|
|
|
@@ -81,6 +81,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
81
81
|
generate.add_argument("--output-directory", type=Path, default=Path("tests") / "generated")
|
|
82
82
|
generate.add_argument("--json", action="store_true")
|
|
83
83
|
|
|
84
|
+
verify_evidence = commands.add_parser(
|
|
85
|
+
"verify-evidence", help="verify generated pytest evidence and artifacts offline"
|
|
86
|
+
)
|
|
87
|
+
verify_evidence.add_argument("evidence", type=Path)
|
|
88
|
+
verify_evidence.add_argument("--json", action="store_true")
|
|
89
|
+
|
|
84
90
|
report = commands.add_parser(
|
|
85
91
|
"report", help="regenerate terminal, JSON, and offline HTML evidence"
|
|
86
92
|
)
|
|
@@ -172,6 +178,7 @@ def _generate_command(args: argparse.Namespace) -> int:
|
|
|
172
178
|
"status": "PYTEST REGRESSION GENERATED",
|
|
173
179
|
"test": str(generated.test_path),
|
|
174
180
|
"fixture": str(generated.fixture_path),
|
|
181
|
+
"evidence": str(generated.evidence_path),
|
|
175
182
|
}
|
|
176
183
|
if bool(getattr(args, "json", False)):
|
|
177
184
|
sys.stdout.write(json_report(data))
|
|
@@ -180,6 +187,24 @@ def _generate_command(args: argparse.Namespace) -> int:
|
|
|
180
187
|
print()
|
|
181
188
|
print(f"Test: {generated.test_path}")
|
|
182
189
|
print(f"Replay fixture: {generated.fixture_path}")
|
|
190
|
+
print(f"Execution evidence: {generated.evidence_path}")
|
|
191
|
+
return int(ExitCode.OK)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _verify_evidence_command(args: argparse.Namespace) -> int:
|
|
195
|
+
evidence = args.evidence
|
|
196
|
+
if not isinstance(evidence, Path):
|
|
197
|
+
raise ConfigurationError("evidence must be a path")
|
|
198
|
+
result = verify_generated_evidence(evidence)
|
|
199
|
+
data = result.as_dict()
|
|
200
|
+
if bool(getattr(args, "json", False)):
|
|
201
|
+
sys.stdout.write(json_report(data))
|
|
202
|
+
else:
|
|
203
|
+
print(data["status"])
|
|
204
|
+
print()
|
|
205
|
+
print(f"Evidence SHA-256: {result.evidence_sha256}")
|
|
206
|
+
print(f"Replay fixture: {result.fixture_path}")
|
|
207
|
+
print(f"Pytest regression: {result.test_path}")
|
|
183
208
|
return int(ExitCode.OK)
|
|
184
209
|
|
|
185
210
|
|
|
@@ -221,6 +246,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
221
246
|
return _replay_command(args)
|
|
222
247
|
if args.command == "generate-test":
|
|
223
248
|
return _generate_command(args)
|
|
249
|
+
if args.command == "verify-evidence":
|
|
250
|
+
return _verify_evidence_command(args)
|
|
224
251
|
if args.command == "report":
|
|
225
252
|
return _report_command(args)
|
|
226
253
|
raise ConfigurationError(f"unknown command {args.command!r}")
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Safe deterministic pytest generation from validated replay fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from phaseprobe import __version__
|
|
13
|
+
from phaseprobe.errors import IntegrityError
|
|
14
|
+
from phaseprobe.replay import validate_fixture, verify_replay
|
|
15
|
+
|
|
16
|
+
MAX_EVIDENCE_BYTES = 1_048_576
|
|
17
|
+
MAX_GENERATED_ARTIFACT_BYTES = 33_554_432
|
|
18
|
+
MAX_JSON_DEPTH = 32
|
|
19
|
+
MAX_JSON_NODES = 10_000
|
|
20
|
+
|
|
21
|
+
_PRODUCER = {
|
|
22
|
+
"repository": "aliengineering-byte/phaseprobe",
|
|
23
|
+
"version": __version__,
|
|
24
|
+
"capability": "validated-replay-to-pytest",
|
|
25
|
+
"documentation": "https://github.com/aliengineering-byte/phaseprobe#five-minute-quick-start",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_LIMITATIONS = [
|
|
29
|
+
"Generation-time replay verifies only the fixture's declared exact or tolerance policy.",
|
|
30
|
+
"The generated pytest detects future mismatch; it does not prove an exact bifurcation point or global minimality.",
|
|
31
|
+
"Artifact hashes are unsigned and must be recomputed by a consumer; the evidence record does not authenticate itself.",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class GeneratedTest:
|
|
37
|
+
"""Generated test, copied fixture, and machine-readable execution evidence."""
|
|
38
|
+
|
|
39
|
+
test_path: Path
|
|
40
|
+
fixture_path: Path
|
|
41
|
+
evidence_path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class GeneratedEvidenceVerification:
|
|
46
|
+
"""Bounded offline validation result for generated regression evidence."""
|
|
47
|
+
|
|
48
|
+
evidence_path: Path
|
|
49
|
+
fixture_path: Path
|
|
50
|
+
test_path: Path
|
|
51
|
+
evidence_sha256: str
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict[str, object]:
|
|
54
|
+
return {
|
|
55
|
+
"schema_version": "1.0",
|
|
56
|
+
"status": "GENERATED EVIDENCE VERIFIED",
|
|
57
|
+
"integrity": "unsigned-recomputable",
|
|
58
|
+
"authentication": "none",
|
|
59
|
+
"evidence_sha256": self.evidence_sha256,
|
|
60
|
+
"artifacts": {
|
|
61
|
+
"replay_fixture": str(self.fixture_path),
|
|
62
|
+
"pytest_regression": str(self.test_path),
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_name(value: object) -> str:
|
|
68
|
+
candidate = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_")
|
|
69
|
+
return candidate[:64] or "model"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reject_conflict(path: Path, expected: bytes, description: str) -> bool:
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return True
|
|
75
|
+
if not path.is_file() or path.read_bytes() != expected:
|
|
76
|
+
raise FileExistsError(
|
|
77
|
+
f"refusing to overwrite existing {description} {path}; "
|
|
78
|
+
"choose another --output-directory"
|
|
79
|
+
)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sha256(data: bytes) -> str:
|
|
84
|
+
return hashlib.sha256(data).hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _classification(payload: object) -> object:
|
|
88
|
+
return payload.get("classification") if isinstance(payload, dict) else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _generated_source(model_name: str, scipy_backed: bool) -> bytes:
|
|
92
|
+
scipy_import = "import pytest\n" if scipy_backed else ""
|
|
93
|
+
scipy_guard = (
|
|
94
|
+
'\npytest.importorskip("scipy")\npytestmark = pytest.mark.scipy\n' if scipy_backed else ""
|
|
95
|
+
)
|
|
96
|
+
source = f'''"""Generated by PhaseProbe from a validated replay fixture."""
|
|
97
|
+
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
|
|
100
|
+
{scipy_import}
|
|
101
|
+
from phaseprobe.replay import verify_replay
|
|
102
|
+
{scipy_guard}
|
|
103
|
+
|
|
104
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "{model_name}-replay.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_{model_name}_transition_replays() -> None:
|
|
108
|
+
"""Re-execute model/config/seed and verify the fixture's declared replay policy."""
|
|
109
|
+
result = verify_replay(FIXTURE)
|
|
110
|
+
assert result.ok, result.as_dict()
|
|
111
|
+
'''
|
|
112
|
+
return source.encode("utf-8")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _strict_object(value: object, required: set[str], description: str) -> dict[str, Any]:
|
|
116
|
+
if not isinstance(value, dict) or set(value) != required:
|
|
117
|
+
raise IntegrityError(f"{description} must contain exactly: {', '.join(sorted(required))}")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
122
|
+
result: dict[str, object] = {}
|
|
123
|
+
for key, value in pairs:
|
|
124
|
+
if key in result:
|
|
125
|
+
raise IntegrityError(f"duplicate JSON key {key!r}")
|
|
126
|
+
result[key] = value
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bounded_json(path: Path, maximum: int) -> tuple[bytes, dict[str, Any]]:
|
|
131
|
+
if not path.is_file():
|
|
132
|
+
raise IntegrityError(f"evidence must be a regular file: {path}")
|
|
133
|
+
if path.stat().st_size > maximum:
|
|
134
|
+
raise IntegrityError(f"evidence exceeds {maximum} bytes")
|
|
135
|
+
raw = path.read_bytes()
|
|
136
|
+
try:
|
|
137
|
+
text = raw.decode("utf-8")
|
|
138
|
+
parsed = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
|
|
139
|
+
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
140
|
+
raise IntegrityError(f"evidence is not strict UTF-8 JSON: {exc}") from exc
|
|
141
|
+
if not isinstance(parsed, dict):
|
|
142
|
+
raise IntegrityError("evidence root must be an object")
|
|
143
|
+
stack: list[tuple[object, int]] = [(parsed, 1)]
|
|
144
|
+
nodes = 0
|
|
145
|
+
while stack:
|
|
146
|
+
current, depth = stack.pop()
|
|
147
|
+
nodes += 1
|
|
148
|
+
if nodes > MAX_JSON_NODES:
|
|
149
|
+
raise IntegrityError(f"evidence exceeds {MAX_JSON_NODES} JSON nodes")
|
|
150
|
+
if depth > MAX_JSON_DEPTH:
|
|
151
|
+
raise IntegrityError(f"evidence exceeds JSON depth {MAX_JSON_DEPTH}")
|
|
152
|
+
if isinstance(current, dict):
|
|
153
|
+
stack.extend((item, depth + 1) for item in current.values())
|
|
154
|
+
elif isinstance(current, list):
|
|
155
|
+
stack.extend((item, depth + 1) for item in current)
|
|
156
|
+
return raw, parsed
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _artifact_path(base: Path, value: object, description: str) -> Path:
|
|
160
|
+
if not isinstance(value, str) or not value or "\\" in value:
|
|
161
|
+
raise IntegrityError(f"{description} path must be a non-empty POSIX relative path")
|
|
162
|
+
path = Path(value)
|
|
163
|
+
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
|
164
|
+
raise IntegrityError(f"unsafe {description} path {value!r}")
|
|
165
|
+
try:
|
|
166
|
+
resolved = (base / path).resolve(strict=True)
|
|
167
|
+
resolved.relative_to(base.resolve(strict=True))
|
|
168
|
+
except (OSError, ValueError) as exc:
|
|
169
|
+
raise IntegrityError(f"{description} escapes or is missing: {value!r}") from exc
|
|
170
|
+
if not resolved.is_file():
|
|
171
|
+
raise IntegrityError(f"{description} must resolve to a regular file")
|
|
172
|
+
if resolved.stat().st_size > MAX_GENERATED_ARTIFACT_BYTES:
|
|
173
|
+
raise IntegrityError(f"{description} exceeds {MAX_GENERATED_ARTIFACT_BYTES} bytes")
|
|
174
|
+
return resolved
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _artifact_record(artifacts: dict[str, Any], name: str, base: Path) -> tuple[Path, bytes]:
|
|
178
|
+
record = _strict_object(artifacts.get(name), {"path", "sha256"}, name)
|
|
179
|
+
path = _artifact_path(base, record["path"], name)
|
|
180
|
+
raw = path.read_bytes()
|
|
181
|
+
expected_hash = record["sha256"]
|
|
182
|
+
if not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
|
183
|
+
raise IntegrityError(f"{name} sha256 must be 64 lowercase hex characters")
|
|
184
|
+
if _sha256(raw) != expected_hash:
|
|
185
|
+
raise IntegrityError(f"{name} sha256 mismatch")
|
|
186
|
+
return path, raw
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def verify_generated_evidence(evidence_path: Path) -> GeneratedEvidenceVerification:
|
|
190
|
+
"""Verify bounded generated-test evidence and derive its important claims offline."""
|
|
191
|
+
|
|
192
|
+
evidence_raw, evidence = _bounded_json(evidence_path, MAX_EVIDENCE_BYTES)
|
|
193
|
+
evidence = _strict_object(
|
|
194
|
+
evidence,
|
|
195
|
+
{
|
|
196
|
+
"schema_version",
|
|
197
|
+
"producer",
|
|
198
|
+
"claim",
|
|
199
|
+
"decision",
|
|
200
|
+
"artifacts",
|
|
201
|
+
"reproduction",
|
|
202
|
+
"limitations",
|
|
203
|
+
},
|
|
204
|
+
"evidence",
|
|
205
|
+
)
|
|
206
|
+
if evidence["schema_version"] != "1.0":
|
|
207
|
+
raise IntegrityError("unsupported generated-evidence schema_version")
|
|
208
|
+
if _strict_object(evidence["producer"], set(_PRODUCER), "producer") != _PRODUCER:
|
|
209
|
+
raise IntegrityError("producer identity does not match this PhaseProbe release")
|
|
210
|
+
claim = _strict_object(
|
|
211
|
+
evidence["claim"],
|
|
212
|
+
{
|
|
213
|
+
"kind",
|
|
214
|
+
"model",
|
|
215
|
+
"baseline_classification",
|
|
216
|
+
"changed_classification",
|
|
217
|
+
"reproducible_at_generation",
|
|
218
|
+
},
|
|
219
|
+
"claim",
|
|
220
|
+
)
|
|
221
|
+
decision = _strict_object(
|
|
222
|
+
evidence["decision"], {"status", "comparison_mode", "comparisons"}, "decision"
|
|
223
|
+
)
|
|
224
|
+
artifacts = _strict_object(
|
|
225
|
+
evidence["artifacts"], {"replay_fixture", "pytest_regression"}, "artifacts"
|
|
226
|
+
)
|
|
227
|
+
reproduction = _strict_object(
|
|
228
|
+
evidence["reproduction"], {"working_directory", "command"}, "reproduction"
|
|
229
|
+
)
|
|
230
|
+
if evidence["limitations"] != _LIMITATIONS:
|
|
231
|
+
raise IntegrityError("limitations do not match the generated-evidence contract")
|
|
232
|
+
|
|
233
|
+
base = evidence_path.resolve(strict=True).parent
|
|
234
|
+
fixture_path, fixture_raw = _artifact_record(artifacts, "replay_fixture", base)
|
|
235
|
+
test_path, test_raw = _artifact_record(artifacts, "pytest_regression", base)
|
|
236
|
+
fixture = validate_fixture(fixture_path)
|
|
237
|
+
replay = verify_replay(fixture_path)
|
|
238
|
+
if not replay.ok:
|
|
239
|
+
raise IntegrityError("replay fixture no longer satisfies its declared policy")
|
|
240
|
+
model_name = _safe_name(fixture.get("model"))
|
|
241
|
+
if artifacts["replay_fixture"]["path"] != f"fixtures/{model_name}-replay.json":
|
|
242
|
+
raise IntegrityError("replay fixture path conflicts with the derived model name")
|
|
243
|
+
if artifacts["pytest_regression"]["path"] != f"test_{model_name}_transition.py":
|
|
244
|
+
raise IntegrityError("pytest path conflicts with the derived model name")
|
|
245
|
+
baseline = fixture.get("baseline")
|
|
246
|
+
metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None
|
|
247
|
+
adapter_configuration = (
|
|
248
|
+
metadata.get("adapter_configuration") if isinstance(metadata, dict) else None
|
|
249
|
+
)
|
|
250
|
+
scipy_backed = (
|
|
251
|
+
isinstance(adapter_configuration, dict)
|
|
252
|
+
and adapter_configuration.get("adapter") == "scipy.solve_ivp"
|
|
253
|
+
)
|
|
254
|
+
if test_raw != _generated_source(model_name, scipy_backed):
|
|
255
|
+
raise IntegrityError("pytest regression is not the fixed template derived from the fixture")
|
|
256
|
+
baseline_classification = _classification(fixture.get("baseline"))
|
|
257
|
+
changed_classification = _classification(fixture.get("changed"))
|
|
258
|
+
expected_kind = (
|
|
259
|
+
"qualitative-simulation-regression"
|
|
260
|
+
if baseline_classification is not None
|
|
261
|
+
and changed_classification is not None
|
|
262
|
+
and baseline_classification != changed_classification
|
|
263
|
+
else "simulation-replay-regression"
|
|
264
|
+
)
|
|
265
|
+
if claim != {
|
|
266
|
+
"kind": expected_kind,
|
|
267
|
+
"model": fixture.get("model"),
|
|
268
|
+
"baseline_classification": baseline_classification,
|
|
269
|
+
"changed_classification": changed_classification,
|
|
270
|
+
"reproducible_at_generation": fixture.get("reproducible"),
|
|
271
|
+
}:
|
|
272
|
+
raise IntegrityError("claim conflicts with the replay fixture")
|
|
273
|
+
if decision != {
|
|
274
|
+
"status": "REPLAY_VERIFIED",
|
|
275
|
+
"comparison_mode": replay.mode,
|
|
276
|
+
"comparisons": [dict(item) for item in replay.comparisons],
|
|
277
|
+
}:
|
|
278
|
+
raise IntegrityError("decision conflicts with replay verification")
|
|
279
|
+
if reproduction != {
|
|
280
|
+
"working_directory": ".",
|
|
281
|
+
"command": f"python -m pytest -q {test_path.name}",
|
|
282
|
+
}:
|
|
283
|
+
raise IntegrityError("reproduction command conflicts with the generated artifact")
|
|
284
|
+
if _sha256(fixture_raw) != artifacts["replay_fixture"]["sha256"]:
|
|
285
|
+
raise IntegrityError("replay fixture changed during verification")
|
|
286
|
+
return GeneratedEvidenceVerification(
|
|
287
|
+
evidence_path=evidence_path.resolve(strict=True),
|
|
288
|
+
fixture_path=fixture_path,
|
|
289
|
+
test_path=test_path,
|
|
290
|
+
evidence_sha256=_sha256(evidence_raw),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def generate_regression_test(fixture: Path, output_directory: Path) -> GeneratedTest:
|
|
295
|
+
"""Validate evidence, copy its fixture, and emit a non-extensible pytest template."""
|
|
296
|
+
|
|
297
|
+
payload = validate_fixture(fixture)
|
|
298
|
+
verification = verify_replay(fixture)
|
|
299
|
+
if not verification.ok:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"replay fixture does not reproduce; refusing to generate a regression test"
|
|
302
|
+
)
|
|
303
|
+
model_name = _safe_name(payload.get("model"))
|
|
304
|
+
fixture_directory = output_directory / "fixtures"
|
|
305
|
+
copied_fixture = fixture_directory / f"{model_name}-replay.json"
|
|
306
|
+
test_path = output_directory / f"test_{model_name}_transition.py"
|
|
307
|
+
evidence_path = output_directory / f"{model_name}-pytest-evidence.json"
|
|
308
|
+
baseline = payload.get("baseline")
|
|
309
|
+
metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None
|
|
310
|
+
adapter_configuration = (
|
|
311
|
+
metadata.get("adapter_configuration") if isinstance(metadata, dict) else None
|
|
312
|
+
)
|
|
313
|
+
scipy_backed = (
|
|
314
|
+
isinstance(adapter_configuration, dict)
|
|
315
|
+
and adapter_configuration.get("adapter") == "scipy.solve_ivp"
|
|
316
|
+
)
|
|
317
|
+
fixture_bytes = fixture.read_bytes()
|
|
318
|
+
source_bytes = _generated_source(model_name, scipy_backed)
|
|
319
|
+
baseline_classification = _classification(payload.get("baseline"))
|
|
320
|
+
changed_classification = _classification(payload.get("changed"))
|
|
321
|
+
claim_kind = (
|
|
322
|
+
"qualitative-simulation-regression"
|
|
323
|
+
if baseline_classification is not None
|
|
324
|
+
and changed_classification is not None
|
|
325
|
+
and baseline_classification != changed_classification
|
|
326
|
+
else "simulation-replay-regression"
|
|
327
|
+
)
|
|
328
|
+
evidence = {
|
|
329
|
+
"schema_version": "1.0",
|
|
330
|
+
"producer": _PRODUCER,
|
|
331
|
+
"claim": {
|
|
332
|
+
"kind": claim_kind,
|
|
333
|
+
"model": payload.get("model"),
|
|
334
|
+
"baseline_classification": baseline_classification,
|
|
335
|
+
"changed_classification": changed_classification,
|
|
336
|
+
"reproducible_at_generation": payload.get("reproducible"),
|
|
337
|
+
},
|
|
338
|
+
"decision": {
|
|
339
|
+
"status": "REPLAY_VERIFIED",
|
|
340
|
+
"comparison_mode": verification.mode,
|
|
341
|
+
"comparisons": [dict(item) for item in verification.comparisons],
|
|
342
|
+
},
|
|
343
|
+
"artifacts": {
|
|
344
|
+
"replay_fixture": {
|
|
345
|
+
"path": copied_fixture.relative_to(output_directory).as_posix(),
|
|
346
|
+
"sha256": _sha256(fixture_bytes),
|
|
347
|
+
},
|
|
348
|
+
"pytest_regression": {
|
|
349
|
+
"path": test_path.relative_to(output_directory).as_posix(),
|
|
350
|
+
"sha256": _sha256(source_bytes),
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
"reproduction": {
|
|
354
|
+
"working_directory": ".",
|
|
355
|
+
"command": f"python -m pytest -q {test_path.name}",
|
|
356
|
+
},
|
|
357
|
+
"limitations": _LIMITATIONS,
|
|
358
|
+
}
|
|
359
|
+
evidence_bytes = (
|
|
360
|
+
json.dumps(evidence, allow_nan=False, indent=2, sort_keys=True) + "\n"
|
|
361
|
+
).encode("utf-8")
|
|
362
|
+
write_fixture = _reject_conflict(copied_fixture, fixture_bytes, "replay fixture")
|
|
363
|
+
write_test = _reject_conflict(test_path, source_bytes, "generated pytest")
|
|
364
|
+
write_evidence = _reject_conflict(evidence_path, evidence_bytes, "generated pytest evidence")
|
|
365
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
fixture_directory.mkdir(exist_ok=True)
|
|
367
|
+
if write_fixture:
|
|
368
|
+
copied_fixture.write_bytes(fixture_bytes)
|
|
369
|
+
if write_test:
|
|
370
|
+
test_path.write_bytes(source_bytes)
|
|
371
|
+
if write_evidence:
|
|
372
|
+
evidence_path.write_bytes(evidence_bytes)
|
|
373
|
+
return GeneratedTest(
|
|
374
|
+
test_path=test_path,
|
|
375
|
+
fixture_path=copied_fixture,
|
|
376
|
+
evidence_path=evidence_path,
|
|
377
|
+
)
|