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 +24 -0
- shotgate/__main__.py +8 -0
- shotgate/backends/__init__.py +19 -0
- shotgate/backends/base.py +53 -0
- shotgate/backends/ibm_runtime.py +153 -0
- shotgate/backends/local_aer.py +46 -0
- shotgate/backends/registry.py +89 -0
- shotgate/circuits/__init__.py +7 -0
- shotgate/circuits/loader.py +57 -0
- shotgate/cli.py +130 -0
- shotgate/config.py +178 -0
- shotgate/pytest_plugin.py +195 -0
- shotgate/report.py +183 -0
- shotgate/runner.py +153 -0
- shotgate/telemetry.py +40 -0
- shotgate/validation/__init__.py +11 -0
- shotgate/validation/assertions.py +240 -0
- shotgate/validation/metrics.py +247 -0
- shotgate-0.1.0.dist-info/METADATA +326 -0
- shotgate-0.1.0.dist-info/RECORD +24 -0
- shotgate-0.1.0.dist-info/WHEEL +4 -0
- shotgate-0.1.0.dist-info/entry_points.txt +5 -0
- shotgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- shotgate-0.1.0.dist-info/licenses/NOTICE +7 -0
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,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,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()
|