shotgate 0.1.0__py3-none-any.whl

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.
shotgate/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """shotgate — container-native CI/CD quality gates for quantum circuits.
4
+
5
+ shotgate validates the *probabilistic* output of quantum circuits using statistical
6
+ oracles (total variation distance, Hellinger fidelity, chi-square goodness-of-fit)
7
+ so that quantum programs can be tested in ordinary CI/CD pipelines, across
8
+ simulators and real QPUs, defined entirely as code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from shotgate.config import Workflow, load_workflow
16
+ from shotgate.validation import Assertion, AssertionResult
17
+
18
+ __all__ = [
19
+ "Assertion",
20
+ "AssertionResult",
21
+ "Workflow",
22
+ "__version__",
23
+ "load_workflow",
24
+ ]
shotgate/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Enable ``python -m shotgate``."""
4
+
5
+ from shotgate.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,19 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Pluggable execution backends for quantum circuits."""
4
+
5
+ from shotgate.backends.base import Backend, BackendResult, BackendUnavailableError
6
+ from shotgate.backends.registry import (
7
+ available_backends,
8
+ get_backend,
9
+ register_backend,
10
+ )
11
+
12
+ __all__ = [
13
+ "Backend",
14
+ "BackendResult",
15
+ "BackendUnavailableError",
16
+ "available_backends",
17
+ "get_backend",
18
+ "register_backend",
19
+ ]
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Backend abstraction.
4
+
5
+ A backend executes a circuit and returns measurement counts. The interface is
6
+ intentionally minimal so new providers (local simulators, IBM, AWS Braket, …) can
7
+ be added without touching the runner or the validation core. Heavy SDKs are
8
+ imported lazily inside concrete backends, never at module import time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import abc
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class BackendResult:
20
+ """Result of executing a circuit on a backend."""
21
+
22
+ counts: dict[str, int]
23
+ shots: int
24
+ backend_name: str
25
+ metadata: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class Backend(abc.ABC):
29
+ """Abstract execution target for a quantum circuit."""
30
+
31
+ #: Stable provider identifier used in workflow YAML (e.g. ``"local-aer"``).
32
+ provider: str = "abstract"
33
+
34
+ def __init__(
35
+ self,
36
+ name: str | None = None,
37
+ options: dict[str, Any] | None = None,
38
+ ) -> None:
39
+ self.name = name
40
+ self.options = options or {}
41
+
42
+ @abc.abstractmethod
43
+ def run(self, circuit: Any, shots: int, seed: int | None = None) -> BackendResult:
44
+ """Execute ``circuit`` for ``shots`` repetitions and return counts."""
45
+
46
+ @classmethod
47
+ def is_available(cls) -> bool:
48
+ """Whether this backend's optional dependencies are importable."""
49
+ return True
50
+
51
+
52
+ class BackendUnavailableError(RuntimeError):
53
+ """Raised when a backend is selected but its dependencies are missing."""
@@ -0,0 +1,153 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """IBM Quantum backend (real QPUs and cloud simulators) via Qiskit Runtime.
4
+
5
+ Credentials are read, in order of precedence, from the backend ``options`` token,
6
+ the ``SHOTGATE_IBM_TOKEN`` environment variable, or the ``QISKIT_IBM_TOKEN``
7
+ environment variable. Nothing is ever written to disk by shotgate.
8
+
9
+ This backend is optional; install it with ``pip install shotgate[ibm]`` or use the
10
+ ``ghcr.io/coldqubit/shotgate:<ver>-ibm`` image variant.
11
+
12
+ Status: **implemented but not yet validated on real hardware.** See
13
+ ``docs/hardware-validation.md`` for the plan to exercise it on an IBM QPU.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ import os
20
+ from typing import Any
21
+
22
+ from shotgate.backends.base import Backend, BackendResult, BackendUnavailableError
23
+
24
+
25
+ def _field(data: Any, name: str) -> Any:
26
+ """Access a DataBin field by name, tolerating mapping- or attribute-style access."""
27
+ try:
28
+ return data[name]
29
+ except (TypeError, KeyError):
30
+ return getattr(data, name)
31
+
32
+
33
+ def _register_names(data: Any) -> list[str]:
34
+ """Return the classical-register field names of a Qiskit V2 ``DataBin``.
35
+
36
+ Recent qiskit exposes them via ``keys()``; we fall back to attribute discovery
37
+ for older/edge shapes.
38
+ """
39
+ if hasattr(data, "keys"):
40
+ try:
41
+ return list(data.keys())
42
+ except Exception:
43
+ # Fall through to attribute discovery for older/edge DataBin shapes.
44
+ pass
45
+ return [n for n in dir(data) if not n.startswith("_")]
46
+
47
+
48
+ def extract_counts(pub_result: Any, register: str | None = None) -> dict[str, int]:
49
+ """Robustly read measurement counts from a Qiskit Runtime SamplerV2 pub result.
50
+
51
+ A V2 result's ``.data`` is a ``DataBin`` whose fields are the circuit's classical
52
+ registers (e.g. ``c`` from ``creg c[2]``, or ``meas`` from ``measure_all``); each
53
+ field is a ``BitArray`` exposing ``get_counts()``.
54
+
55
+ - If ``register`` is given, that register is used (error if absent).
56
+ - If exactly one register carries counts, it is used.
57
+ - Zero or multiple ambiguous registers raise a clear error rather than guessing.
58
+ """
59
+ data = pub_result.data
60
+ names = _register_names(data)
61
+ counts_fields = [
62
+ n for n in names if hasattr(_field(data, n), "get_counts")
63
+ ]
64
+
65
+ if register is not None:
66
+ if register not in counts_fields:
67
+ raise RuntimeError(
68
+ f"requested classical register {register!r} not found in result; "
69
+ f"available registers with counts: {counts_fields or names}"
70
+ )
71
+ chosen = register
72
+ elif len(counts_fields) == 1:
73
+ chosen = counts_fields[0]
74
+ elif not counts_fields:
75
+ raise RuntimeError(
76
+ "no classical register with counts found in the Sampler result "
77
+ f"(fields seen: {names}); ensure the circuit contains measurements"
78
+ )
79
+ else:
80
+ raise RuntimeError(
81
+ f"multiple classical registers {counts_fields} found; disambiguate by "
82
+ "setting backend.options.register to one of them"
83
+ )
84
+
85
+ bit_array = _field(data, chosen)
86
+ return {str(k): int(v) for k, v in bit_array.get_counts().items()}
87
+
88
+
89
+ class IBMRuntimeBackend(Backend):
90
+ provider = "ibm"
91
+
92
+ @classmethod
93
+ def is_available(cls) -> bool:
94
+ return (
95
+ importlib.util.find_spec("qiskit") is not None
96
+ and importlib.util.find_spec("qiskit_ibm_runtime") is not None
97
+ )
98
+
99
+ def _token(self) -> str:
100
+ token = (
101
+ self.options.get("token")
102
+ or os.environ.get("SHOTGATE_IBM_TOKEN")
103
+ or os.environ.get("QISKIT_IBM_TOKEN")
104
+ )
105
+ if not token:
106
+ raise BackendUnavailableError(
107
+ "IBM backend requires an API token via backend.options.token, "
108
+ "SHOTGATE_IBM_TOKEN, or QISKIT_IBM_TOKEN"
109
+ )
110
+ return token
111
+
112
+ def run(self, circuit: Any, shots: int, seed: int | None = None) -> BackendResult:
113
+ from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
114
+ from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
115
+
116
+ # "ibm_quantum_platform" is the current channel; the legacy "ibm_quantum"
117
+ # name was removed in qiskit-ibm-runtime. Override via options if needed.
118
+ channel = self.options.get("channel", "ibm_quantum_platform")
119
+ instance = self.options.get("instance")
120
+ service = QiskitRuntimeService(
121
+ channel=channel, token=self._token(), instance=instance
122
+ )
123
+
124
+ if self.name:
125
+ backend = service.backend(self.name)
126
+ else:
127
+ backend = service.least_busy(operational=True, simulator=False)
128
+
129
+ pass_manager = generate_preset_pass_manager(
130
+ optimization_level=int(self.options.get("optimization_level", 1)),
131
+ backend=backend,
132
+ )
133
+ isa_circuit = pass_manager.run(circuit)
134
+
135
+ sampler = SamplerV2(mode=backend)
136
+ job = sampler.run([isa_circuit], shots=shots)
137
+ pub_result = job.result()[0]
138
+
139
+ # Robustly read counts from the named classical register(s) of the result.
140
+ counts = extract_counts(pub_result, register=self.options.get("register"))
141
+
142
+ return BackendResult(
143
+ counts=counts,
144
+ shots=shots,
145
+ backend_name=backend.name,
146
+ metadata={
147
+ "provider": self.provider,
148
+ "job_id": job.job_id(),
149
+ "channel": channel,
150
+ # seed is honored only on cloud simulators; ignored on real QPUs.
151
+ "seed_requested": seed,
152
+ },
153
+ )
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Local Qiskit Aer simulator backend.
4
+
5
+ This is the default, zero-cost, fully-offline execution target: it runs entirely
6
+ inside the shotgate container with no cloud credentials. Ideal for fast CI gating on
7
+ small circuits (the report's "5-8 qubits, zero local RAM" MVP target).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ from typing import Any
14
+
15
+ from shotgate.backends.base import Backend, BackendResult
16
+
17
+
18
+ class LocalAerBackend(Backend):
19
+ provider = "local-aer"
20
+
21
+ @classmethod
22
+ def is_available(cls) -> bool:
23
+ return (
24
+ importlib.util.find_spec("qiskit") is not None
25
+ and importlib.util.find_spec("qiskit_aer") is not None
26
+ )
27
+
28
+ def run(self, circuit: Any, shots: int, seed: int | None = None) -> BackendResult:
29
+ from qiskit import transpile
30
+ from qiskit_aer import AerSimulator
31
+
32
+ simulator = AerSimulator(**self.options)
33
+ compiled = transpile(circuit, simulator)
34
+ result = simulator.run(compiled, shots=shots, seed_simulator=seed).result()
35
+ counts = {str(k): int(v) for k, v in result.get_counts().items()}
36
+
37
+ return BackendResult(
38
+ counts=counts,
39
+ shots=shots,
40
+ backend_name=self.name or "aer_simulator",
41
+ metadata={
42
+ "provider": self.provider,
43
+ "method": simulator.options.get("method", "automatic"),
44
+ "seed": seed,
45
+ },
46
+ )
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Backend registry with lazy, dependency-free dispatch.
4
+
5
+ Concrete backends are referenced by import path and only imported when actually
6
+ requested, so importing shotgate (and running the validation core) never requires a
7
+ quantum SDK to be installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+
14
+ from shotgate.backends.base import Backend, BackendUnavailableError
15
+ from shotgate.config import BackendSpec
16
+
17
+ # provider name -> "module:ClassName"
18
+ _REGISTRY: dict[str, str] = {
19
+ "local-aer": "shotgate.backends.local_aer:LocalAerBackend",
20
+ "ibm": "shotgate.backends.ibm_runtime:IBMRuntimeBackend",
21
+ }
22
+
23
+ # provider name -> pip extra that supplies its dependencies
24
+ _PROVIDER_EXTRA: dict[str, str] = {
25
+ "local-aer": "aer",
26
+ "ibm": "ibm",
27
+ }
28
+
29
+
30
+ def extra_for_provider(provider: str) -> str:
31
+ """Return the pip extra (``shotgate[<extra>]``) that supplies a provider's deps."""
32
+ return _PROVIDER_EXTRA.get(provider, provider)
33
+
34
+
35
+ def _load_class(provider: str) -> type[Backend]:
36
+ try:
37
+ target = _REGISTRY[provider]
38
+ except KeyError:
39
+ raise ValueError(
40
+ f"unknown backend provider {provider!r}; "
41
+ f"available: {', '.join(sorted(_REGISTRY))}"
42
+ ) from None
43
+ module_name, class_name = target.split(":")
44
+ module = importlib.import_module(module_name)
45
+ return getattr(module, class_name)
46
+
47
+
48
+ def get_backend(spec: BackendSpec) -> Backend:
49
+ """Instantiate the backend described by ``spec``.
50
+
51
+ Raises :class:`BackendUnavailableError` if the backend's optional
52
+ dependencies are not installed.
53
+ """
54
+ backend_cls = _load_class(spec.provider)
55
+ if not backend_cls.is_available():
56
+ extra = extra_for_provider(spec.provider)
57
+ raise BackendUnavailableError(
58
+ f"backend {spec.provider!r} is selected but its dependencies are not "
59
+ f"installed. Install the matching extra to use shotgate from a pip "
60
+ f"install (CLI or pytest plugin): pip install 'shotgate[{extra}]'. "
61
+ f"The published image ghcr.io/coldqubit/shotgate bakes the aer backend "
62
+ f"in (use the :latest-ibm tag for ibm)."
63
+ )
64
+ return backend_cls(name=spec.name, options=spec.options)
65
+
66
+
67
+ def available_backends() -> dict[str, bool]:
68
+ """Map each known provider to whether its dependencies are importable."""
69
+ status: dict[str, bool] = {}
70
+ for provider in _REGISTRY:
71
+ try:
72
+ status[provider] = _load_class(provider).is_available()
73
+ except Exception:
74
+ status[provider] = False
75
+ return status
76
+
77
+
78
+ def register_backend(provider: str, target: str) -> None:
79
+ """Register a third-party backend as ``"module:ClassName"`` (plugin hook)."""
80
+ _REGISTRY[provider] = target
81
+
82
+
83
+ __all__ = [
84
+ "BackendUnavailableError",
85
+ "available_backends",
86
+ "extra_for_provider",
87
+ "get_backend",
88
+ "register_backend",
89
+ ]
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Circuit loading utilities."""
4
+
5
+ from shotgate.circuits.loader import load_circuit
6
+
7
+ __all__ = ["load_circuit"]
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Load quantum circuits from a :class:`~shotgate.config.CircuitSpec`.
4
+
5
+ OpenQASM (2.0 or 3.0) is the interchange format: it is portable across SDKs and
6
+ keeps workflows free of executable Python, which matters for a tool that runs
7
+ untrusted circuits in CI. Qiskit is imported lazily so the validation core stays
8
+ dependency-free.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from shotgate.config import CircuitSpec
17
+
18
+
19
+ def load_circuit(spec: CircuitSpec, base_dir: Path) -> Any:
20
+ """Return a Qiskit ``QuantumCircuit`` for ``spec``.
21
+
22
+ If the loaded circuit has no classical bits, a full measurement is appended so
23
+ that the backend produces counts.
24
+ """
25
+ text = _read_source(spec, base_dir)
26
+ circuit = _parse_qasm(text, spec.format)
27
+
28
+ if circuit.num_clbits == 0:
29
+ circuit.measure_all()
30
+ return circuit
31
+
32
+
33
+ def _read_source(spec: CircuitSpec, base_dir: Path) -> str:
34
+ if spec.inline is not None:
35
+ return spec.inline
36
+ assert spec.path is not None # guaranteed by CircuitSpec validation
37
+ circuit_path = (base_dir / spec.path).expanduser()
38
+ if not circuit_path.is_file():
39
+ raise FileNotFoundError(f"circuit file not found: {circuit_path}")
40
+ return circuit_path.read_text(encoding="utf-8")
41
+
42
+
43
+ def _parse_qasm(text: str, fmt: str) -> Any:
44
+ if fmt == "qasm2":
45
+ from qiskit import qasm2
46
+
47
+ return qasm2.loads(
48
+ text, custom_instructions=qasm2.LEGACY_CUSTOM_INSTRUCTIONS
49
+ )
50
+ if fmt == "qasm3":
51
+ from qiskit import qasm3
52
+
53
+ return qasm3.loads(text)
54
+ raise ValueError(f"unsupported circuit format: {fmt!r}")
55
+
56
+
57
+ __all__ = ["load_circuit"]
shotgate/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 coldqubit
3
+ """Command-line interface for shotgate.
4
+
5
+ Commands
6
+ --------
7
+ - ``shotgate run WORKFLOW`` execute a workflow and gate CI on the result.
8
+ - ``shotgate validate WORKFLOW`` schema-validate a workflow without executing it.
9
+ - ``shotgate backends`` list backends and whether their deps are installed.
10
+
11
+ Exit codes: ``0`` if all assertions pass, ``1`` if any fail, ``2`` for usage or
12
+ load errors. This makes ``shotgate run`` a drop-in CI quality gate.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from shotgate import __version__
22
+
23
+
24
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
25
+ @click.version_option(__version__, prog_name="shotgate")
26
+ def main() -> None:
27
+ """Container-native CI/CD quality gates for quantum circuits."""
28
+
29
+
30
+ @main.command()
31
+ @click.argument("workflow", type=click.Path(exists=True, dir_okay=False, path_type=Path))
32
+ @click.option("--backend", "backend", default=None, help="Override the backend provider.")
33
+ @click.option("--shots", type=int, default=None, help="Override shot count for all jobs.")
34
+ @click.option(
35
+ "--junit",
36
+ type=click.Path(dir_okay=False, path_type=Path),
37
+ default=None,
38
+ help="Write a JUnit XML report to this path.",
39
+ )
40
+ @click.option(
41
+ "--json",
42
+ "json_path",
43
+ type=click.Path(dir_okay=False, path_type=Path),
44
+ default=None,
45
+ help="Write a JSON report to this path.",
46
+ )
47
+ @click.option(
48
+ "--markdown",
49
+ type=click.Path(dir_okay=False, path_type=Path),
50
+ default=None,
51
+ help="Write a Markdown summary to this path (e.g. $GITHUB_STEP_SUMMARY).",
52
+ )
53
+ @click.option("--quiet", is_flag=True, help="Suppress the console table.")
54
+ def run(
55
+ workflow: Path,
56
+ backend: str | None,
57
+ shots: int | None,
58
+ junit: Path | None,
59
+ json_path: Path | None,
60
+ markdown: Path | None,
61
+ quiet: bool,
62
+ ) -> None:
63
+ """Execute WORKFLOW: run each job, validate output, and report."""
64
+ import typing
65
+
66
+ from shotgate.config import ProviderName, load_workflow
67
+ from shotgate.report import render_console, to_json, to_junit_xml, to_markdown
68
+ from shotgate.runner import Runner
69
+
70
+ valid_backends = typing.get_args(ProviderName)
71
+ if backend is not None and backend not in valid_backends:
72
+ raise click.ClickException(
73
+ f"unknown backend {backend!r}; valid backends: {', '.join(valid_backends)}"
74
+ )
75
+
76
+ try:
77
+ loaded = load_workflow(workflow)
78
+ except Exception as exc:
79
+ raise click.ClickException(f"failed to load workflow: {exc}") from exc
80
+
81
+ report = Runner(loaded, backend_override=backend, shots_override=shots).run()
82
+
83
+ if not quiet:
84
+ render_console(report)
85
+
86
+ if junit:
87
+ junit.write_text(to_junit_xml(report), encoding="utf-8")
88
+ if json_path:
89
+ json_path.write_text(to_json(report), encoding="utf-8")
90
+ if markdown:
91
+ markdown.write_text(to_markdown(report), encoding="utf-8")
92
+
93
+ raise SystemExit(0 if report.passed else 1)
94
+
95
+
96
+ @main.command()
97
+ @click.argument("workflow", type=click.Path(exists=True, dir_okay=False, path_type=Path))
98
+ def validate(workflow: Path) -> None:
99
+ """Schema-validate WORKFLOW without executing any circuit."""
100
+ from shotgate.config import load_workflow
101
+
102
+ try:
103
+ loaded = load_workflow(workflow)
104
+ except Exception as exc:
105
+ raise click.ClickException(str(exc)) from exc
106
+
107
+ wf = loaded.workflow
108
+ click.secho(f"✓ valid workflow: {wf.metadata.name}", fg="green")
109
+ for job in wf.jobs:
110
+ spec = wf.effective_backend(job)
111
+ click.echo(
112
+ f" - job {job.name}: {len(job.assertions)} assertion(s), "
113
+ f"backend={spec.provider}, shots={spec.shots}"
114
+ )
115
+
116
+
117
+ @main.command()
118
+ def backends() -> None:
119
+ """List available backends and whether their dependencies are installed."""
120
+ from shotgate.backends.registry import available_backends
121
+
122
+ for provider, ready in available_backends().items():
123
+ mark = click.style("ready", fg="green") if ready else click.style(
124
+ "missing deps", fg="yellow"
125
+ )
126
+ click.echo(f" {provider:<12} {mark}")
127
+
128
+
129
+ if __name__ == "__main__": # pragma: no cover
130
+ main()