Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
89f8a7c
default equal_nan to true for snapshot comparison
atharvas May 15, 2026
d43a2a0
make benchmark roundtrip tests regression-based and python 3.8-safe
atharvas May 15, 2026
a111c82
bump version to 0.2.1
atharvas May 15, 2026
60f1a2b
document equal_nan default and regression-based roundtrip
atharvas May 15, 2026
4d29f35
keep verify/baseline/capture running when a benchmark module fails to…
atharvas May 15, 2026
93af712
cap per-benchmark timeout at 5s for the large astropy/pandas shards
atharvas May 16, 2026
838e415
extract value-serialization helpers to module level in storage
atharvas May 17, 2026
cd2a291
add opt-in --parallel process-pool execution for capture/verify/baseline
atharvas May 17, 2026
f056d40
run astropy/pandas roundtrip shards with --parallel
atharvas May 17, 2026
6b50f32
ignore .claude/ and astropy .fits benchmark artifacts
atharvas May 17, 2026
f6ac822
recover serially when a parallel worker dies instead of failing the b…
atharvas May 17, 2026
c3ef253
tolerate unpicklable benchmark params instead of aborting capture
atharvas May 17, 2026
27a0569
preserve param_names list type in unpicklable-param fallback
atharvas May 17, 2026
b5d4928
compare pandas DataFrame/Series with tolerance instead of failing
atharvas May 17, 2026
f0eed63
make baseline-to-verify gate robust to non-deterministic benchmarks
atharvas May 17, 2026
35e5a5e
report regressed benchmark names in verify summary
atharvas May 17, 2026
aa49abd
drop stability-aware baseline; it doubled baseline time on large shards
atharvas May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/test-astropy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ jobs:
env:
UV_PYTHON: ${{ env.pythonLocation }}/bin/python
SNAPSHOT_TOOL_FILTER: ${{ matrix.shard.filter }}
SNAPSHOT_TOOL_TIMEOUT: "10"
# Aggressive per-benchmark cap: the roundtrip runs capture+baseline+verify
# (3 passes) over large shards. Slow benchmarks become failed-captures
# (skipped), which the regression gate tolerates, instead of blowing the
# job time budget. See tests/test_cli_roundtrip.py.
SNAPSHOT_TOOL_TIMEOUT: "5"
# Run benchmarks across worker processes (min(cpu_count, 8)); produces
# byte-identical snapshots to serial. The main lever for fitting these
# large shards in the time budget.
SNAPSHOT_TOOL_PARALLEL: "1"
run: |
uv run pytest -v tests/test_cli_roundtrip.py::TestAstropyRoundtrip -x
timeout-minutes: 90
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/test-pandas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ jobs:
env:
UV_PYTHON: ${{ env.pythonLocation }}/bin/python
SNAPSHOT_TOOL_FILTER: ${{ matrix.shard.filter }}
SNAPSHOT_TOOL_TIMEOUT: "45"
# Aggressive per-benchmark cap: the roundtrip runs capture+baseline+verify
# (3 passes) and the pandas `core` shard alone is ~831 benchmarks with
# heavy parameter expansion. Slow benchmarks become failed-captures
# (skipped), which the regression gate tolerates, instead of blowing the
# job time budget. See tests/test_cli_roundtrip.py.
SNAPSHOT_TOOL_TIMEOUT: "5"
# Run benchmarks across worker processes (min(cpu_count, 8)); produces
# byte-identical snapshots to serial. The main lever for fitting the
# ~831-benchmark `core` shard in the time budget.
SNAPSHOT_TOOL_PARALLEL: "1"
run: |
uv run pytest -v tests/test_cli_roundtrip.py::TestPandasRoundtrip -x
timeout-minutes: 90
Expand Down
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,10 @@ tests/.snapshots/
**/.snapshots

# Snapshot testing tool output
summary.json
summary.json

# Astropy benchmark artifacts written to CWD during local capture runs
*.fits

# Claude Code local session state
.claude/
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ The package lives under `src/snapshot_tool/`. The pipeline is intentionally a ch
- Caches loaded modules and class-level `setup_cache()` results.
- Wraps each run in a `ThreadPoolExecutor.submit(...).result(timeout=...)` so per-benchmark timeouts are enforceable (default 300 s from the CLI). On timeout the future is cancelled but the thread keeps running — be aware this can leak threads on hangs.
- For class-based benchmarks calls `setup_cache` (once), then `setup(*params)` before the benchmark method. For methods declared with parameters but invoked without, it falls back to the first parameter combination and logs a warning.
- Optional opt-in parallelism: `capture`/`verify`/`baseline` accept `--parallel` (and `--workers N`, default `min(cpu_count, 8)`). `runner.iter_task_results` dispatches `(benchmark, params)` tasks to a `ProcessPoolExecutor` (a per-worker `BenchmarkRunner` is built once via the pool initializer so its module/setup_cache caches are reused). Workers only execute+trace+`serialize_value` the result; the **main process keeps every SQLite write** (SQLite is single-writer) and does all comparison. Default is serial (unchanged). Validated to produce byte-identical (same sha256 blob hashes) snapshots as serial. Tasks whose `(benchmark, params)` can't be pickled fall back to in-process execution.

5. **`storage.py`** — `SnapshotManager` keeps all captured values in a single SQLite database at `<snapshot_dir>/snapshots.db`. The schema has two tables joined by `blob_hash`: `blobs(hash, data, refcount, raw_size, compressed_size)` content-addresses each gzipped pickle by sha256 (so benchmarks producing identical outputs share a single blob), and `snapshots(test_id PRIMARY KEY, ...)` carries per-test metadata. PRAGMAs: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`. A JSON metadata sidecar is still written per snapshot at `<snapshot_dir>/<module>/<class.benchmark>/<param-hash>.json` for downstream tooling — `store_snapshot` returns that path. `SnapshotManager` also owns `baseline.json` (kept as a flat JSON file) — the test_id->status map produced by `baseline` and consumed by `verify`. There is no `.pkl` / `.pkl.gz` read path; the SQLite backend is a clean break.

6. **`comparator.py`** — `Comparator` performs tolerance-aware comparison. Numpy is imported lazily and detected via module/type-name probing (`_is_numpy_array`), so the package itself does not depend on numpy. Pure-Python `_py_isclose` mirrors `numpy.isclose` semantics for scalars.
6. **`comparator.py`** — `Comparator` performs tolerance-aware comparison. Numpy is imported lazily and detected via module/type-name probing (`_is_numpy_array`), so the package itself does not depend on numpy. Pure-Python `_py_isclose` mirrors `numpy.isclose` semantics for scalars. **`ComparisonConfig.equal_nan` defaults to `True`** (and so does `SnapshotConfig.tolerance["equal_nan"]`): for snapshot regression testing a deterministic NaN that reappears unchanged is not a change. Pass `equal_nan=False` for strict `numpy.isclose` semantics.

7. **`transitions.py`** — pure function `compute_transitions(baseline, verify)` returning the 9-cell pass/fail/skip transition matrix. Legacy status `"failed_to_pass"` is normalized to `"fail"`.

Expand All @@ -85,6 +86,7 @@ Public API is re-exported in `src/snapshot_tool/__init__.py`; prefer adding to `
- **Python 3.8+ compatibility is enforced.** `pyproject.toml` pins `target-version = "py38"` and ignores `UP007` (no `X | Y` unions). Every module uses `from __future__ import annotations`; keep this for any new file. The full test matrix in CI covers 3.8 -> 3.13.
- Ruff lints with `E,W,F,I,B,C4,UP` and ignores `E501`; formatter is the source of truth (CI runs `ruff format --check`).
- `tests/test_repos/` is excluded by `norecursedirs` in `pyproject.toml` — those directories are vendored benchmark sources (astropy, pandas, shapely), not test files. They are exercised only through `tests/test_cli_roundtrip.py`, which CI runs in dedicated `test-astropy.yml` / `test-pandas.yml` / `test-shapely.yml` jobs sharded by benchmark module regex.
- `tests/test_cli_roundtrip.py` is a **regression** gate, not a perfection gate. It runs `list -> capture -> baseline -> verify` and asserts the baseline→verify transition matrix has **no `pass-to-fail` / `skip-to-fail`** (read from `verify`'s `--summary` JSON). Real third-party suites contain inherently un-snapshotable benchmarks (memory addresses in reprs, timing-sensitive, dtype-unstable); those stay `fail-to-fail` and are tolerated. Don't reintroduce a "zero failures" assertion.
- `pytest -v --strict-markers` is configured; the `slow` marker is registered for full-repo roundtrips. Don't introduce new markers without registering them.
- The package's logger is configured at import time via `configure_logging()` in `__init__.py`. Use `logging.getLogger(__name__)` in submodules — don't add new root-level handlers.
- Snapshot files are written under `.snapshots/` (or wherever `--snapshot-dir` points). `customtest.sh` blows that directory away before each run; treat it as disposable build output, not source.
2 changes: 1 addition & 1 deletion docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ from snapshot_tool import Comparator, ComparisonConfig
config = ComparisonConfig(
rtol=1e-5,
atol=1e-8,
equal_nan=False,
equal_nan=True,
strict_types=True,
strict_shapes=True,
)
Expand Down
19 changes: 19 additions & 0 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ These apply to every subcommand:
| `--verbose`, `-v` | Verbose logging (prints comparison details and full tracebacks on errors) |
| `--quiet`, `-q` | Suppress per-benchmark `[PASS]` / `[SKIP]` lines |

## Parallel execution

`capture`, `verify`, and `baseline` accept opt-in parallelism:

| Flag | Description | Default |
|------|-------------|---------|
| `--parallel` | Run benchmarks across worker **processes** instead of serially | off (serial) |
| `--workers N` | Worker count when `--parallel` is set | `min(cpu_count, 8)` |

Default behaviour is unchanged — without `--parallel` everything runs serially in-process. With `--parallel`, `(benchmark, parameters)` tasks are distributed to a process pool (true parallelism + per-process `sys.settrace`/RNG/import isolation). Workers only execute, trace, and serialize the captured value; the **main process performs every SQLite write** (SQLite is single-writer) and all comparison. Parallel runs are verified to produce **byte-identical** snapshots to serial (same content-addressed sha256 blob hashes), so determinism is preserved. A task whose parameters can't be pickled across the process boundary transparently falls back to in-process execution.

```bash
# Capture a large suite using all cores (capped at 8)
snapshot-tool capture path/to/benchmarks --parallel

# Pin the worker count
snapshot-tool verify path/to/benchmarks --parallel --workers 4
```

## Subcommands

---
Expand Down
6 changes: 4 additions & 2 deletions docs/guide/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ from snapshot_tool import Comparator, ComparisonConfig
config = ComparisonConfig(
rtol=1e-5, # relative tolerance
atol=1e-8, # absolute tolerance
equal_nan=False, # if True, NaN == NaN
equal_nan=True, # NaN at same position == NaN (snapshot default)
strict_types=True, # error on dtype mismatches for numpy arrays
strict_shapes=True, # error on shape mismatches for numpy arrays
ignore_order=False, # (reserved; not currently honored by built-in strategies)
Expand All @@ -24,7 +24,7 @@ result = comparator.compare(actual, expected)
|-------|---------|--------------|
| `rtol` | `1e-5` | Relative tolerance applied to `\|a - b\| <= atol + rtol * \|b\|` |
| `atol` | `1e-8` | Absolute tolerance, same formula |
| `equal_nan` | `False` | If `True`, two NaN values compare equal |
| `equal_nan` | `True` | Two NaN values at the same position compare equal. Defaults `True` because, for snapshot testing, a deterministic NaN that reappears unchanged is not a regression. Set `False` for `numpy.isclose` semantics. |
| `strict_types` | `True` | Numpy array `dtype` mismatch is a failure |
| `strict_shapes` | `True` | Numpy array `shape` mismatch is a failure |

Expand Down Expand Up @@ -86,6 +86,8 @@ def _py_isclose(a, b, rtol=1e-5, atol=1e-8, equal_nan=False):
return abs(a - b) <= atol + rtol * abs(b)
```

The helper's own `equal_nan` parameter defaults to `False` (mirroring `numpy.isclose`), but `Comparator` always passes it explicitly from `ComparisonConfig.equal_nan`, which **defaults to `True`** — so in practice scalar and array NaNs compare equal unless you opt into strict semantics.

The formula is **asymmetric** in `b` — that's intentional; it matches numpy's semantics. In `snapshot-tool`, `b` is always the *expected* (snapshot) value, so the tolerance scales with the magnitude of what you originally captured.

## Numpy array comparison without importing numpy
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ writes a default `snapshot_config.json` with every field at its built-in default
"tolerance": {
"rtol": 1e-5,
"atol": 1e-8,
"equal_nan": false
"equal_nan": true
},
"exclude_benchmarks": [],
"trace_depth_limit": 100,
Expand Down Expand Up @@ -51,7 +51,7 @@ snapshot-tool config --show
|-------|---------|--------------|
| `tolerance.rtol` | `1e-5` | Relative tolerance for numeric comparison. Overridden by the first value of `--tolerance RTOL ATOL`. |
| `tolerance.atol` | `1e-8` | Absolute tolerance. Overridden by the second value of `--tolerance RTOL ATOL`. |
| `tolerance.equal_nan` | `false` | When `true`, two NaN values compare equal. No CLI override — set in the file. |
| `tolerance.equal_nan` | `true` | When `true`, two NaN values at the same position compare equal (snapshot default — a deterministic NaN that reappears unchanged is not a regression). Set `false` for strict `numpy.isclose` semantics. No CLI override — set in the file. |

The comparison formula is `|a - b| <= atol + rtol * |b|`, matching `numpy.isclose` semantics. `b` is the *expected* (snapshot) value.

Expand Down Expand Up @@ -113,7 +113,7 @@ resolves to:

- `snapshot_dir = "./snapshots"` (from `--snapshot-dir`, overriding `conf.json`).
- `tolerance.rtol = 1e-4`, `tolerance.atol = 1e-6` (from `--tolerance`).
- `tolerance.equal_nan = <value from conf.json or false>` (not overridable on the CLI).
- `tolerance.equal_nan = <value from conf.json or true>` (not overridable on the CLI).
- `verbose = True` (from `-v`).

## Programmatic configuration
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "snapshot-tool"
version = "0.2.0"
version = "0.2.1"
description = "Snapshot testing tool for ASV benchmarks - captures and compares function outputs to verify correctness after optimizations"
readme = "README.md"
authors = [
Expand Down
2 changes: 1 addition & 1 deletion src/snapshot_tool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
import sys

__version__ = "0.2.0"
__version__ = "0.2.1"


# Configure logging for the package
Expand Down
Loading
Loading