Source code for qtest.snapshot

"""Snapshot (golden-file) testing for measurement distributions.

The first time a snapshot test runs (or whenever ``--qtest-snapshot-update`` is
passed) qtest samples the circuit and writes the resulting distribution to a
JSON *golden file* next to your test. On later runs it re-samples and compares
against the stored baseline with the usual statistical metric — so you can lock
in a circuit's behaviour and get alerted when a refactor changes it, without
hand-writing the expected distribution.

Use it through the :func:`qtest_snapshot` pytest fixture::

    def test_my_circuit(qtest_snapshot):
        qc = build_circuit()
        qc.measure_all()
        qtest_snapshot.assert_distribution_close(qc, shots=4096, tolerance=0.1)

Golden files live in a ``__qtest_snapshots__/`` directory beside the test file
and should be committed to version control. Refresh them deliberately with::

    pytest --qtest-snapshot-update
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

_SNAPSHOT_VERSION = 1
_SANITIZE_RE = re.compile(r"[^A-Za-z0-9_.-]+")


def _sanitize(name: str) -> str:
    """Make *name* safe to use as a filename component."""
    cleaned = _SANITIZE_RE.sub("_", name).strip("_")
    return cleaned or "snapshot"


[docs] class Snapshot: """A handle to the golden files for one test. Usually obtained from the :func:`qtest_snapshot` fixture rather than constructed directly. Parameters ---------- directory Directory holding this test's golden files (created on demand). default_name Snapshot name used when a call does not pass an explicit ``name`` (typically derived from the test's node id). update When ``True`` (``--qtest-snapshot-update``), golden files are (re)written instead of compared. """ def __init__(self, directory: Path, default_name: str, update: bool = False) -> None: self.directory = Path(directory) self.default_name = default_name self.update = update
[docs] def path_for(self, name: str | None = None) -> Path: """Return the golden-file path for *name* (or the default name).""" return self.directory / f"{_sanitize(name or self.default_name)}.json"
[docs] def assert_distribution_close( self, circuit: Any, name: str | None = None, shots: int | None = None, tolerance: float | None = None, metric: str | None = None, backend: Any | None = None, seed: int | None = None, noise_model: Any | None = None, msg: str | None = None, ) -> None: """Compare *circuit*'s distribution against a stored snapshot. On the first run (no golden file yet) or under ``--qtest-snapshot-update`` the measured distribution is written to disk and the check passes. Otherwise the circuit is re-sampled and compared to the stored distribution via :func:`qtest.assert_distribution_close`. Parameters ---------- circuit A backend-native circuit with measurements. name Snapshot name (allows multiple snapshots per test). Defaults to the test's node-id-derived name. shots, tolerance, metric, backend, seed, noise_model, msg Forwarded to sampling / comparison; ``None`` means "use config". """ from qtest.assertions.distribution import assert_distribution_close from qtest.config import _resolve_value path = self.path_for(name) if self.update or not path.exists(): measured = self._sample(circuit, shots, backend, seed, noise_model) self._write(path, measured, metric=_resolve_value("statistical_metric", metric)) return stored = self._read(path) assert_distribution_close( circuit, expected=stored["distribution"], shots=shots, tolerance=tolerance, metric=metric, backend=backend, seed=seed, noise_model=noise_model, msg=msg or f"Snapshot mismatch vs {path.name}", )
# ------------------------------------------------------------------ # # Internals # # ------------------------------------------------------------------ # def _sample( self, circuit: Any, shots: int | None, backend: Any | None, seed: int | None, noise_model: Any | None, ) -> dict[str, float]: """Sample *circuit* and return its normalised distribution.""" from qtest.assertions.distribution import _has_measurements, _normalize_counts from qtest.backends.registry import get_backend from qtest.config import _resolve_value, get_config shots = _resolve_value("default_shots", shots) seed = _resolve_value("default_seed", seed) if not _has_measurements(circuit): raise ValueError( "circuit has no measurement instructions; snapshot tests require " "a circuit that produces classical bitstrings." ) if backend is None: backend = get_backend(get_config().default_backend) run_kwargs: dict[str, Any] = {"shots": shots, "seed": seed} if noise_model is not None: run_kwargs["noise_model"] = noise_model raw = backend.run_circuit(circuit, **run_kwargs) counts = {k.replace(" ", ""): v for k, v in raw.items()} return _normalize_counts(counts) def _write(self, path: Path, distribution: dict[str, float], metric: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) payload = { "qtest_snapshot_version": _SNAPSHOT_VERSION, "metric": metric, "distribution": {k: distribution[k] for k in sorted(distribution)}, } path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") def _read(self, path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict) or "distribution" not in data: raise ValueError(f"Malformed qtest snapshot file: {path}") return data