spinorio 1.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.
spinorio/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Spinorio: one client for Workflow, Compile and Solvers, locally and in Lab."""
2
+
3
+ from .client import Spinorio, APIError, SubmissionUncertain
4
+
5
+ __version__ = "1.1.0"
6
+ __all__ = ["Spinorio", "APIError", "SubmissionUncertain"]
@@ -0,0 +1,112 @@
1
+ """Credential resolution for local Python and authenticated Spinorio Lab kernels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ PUBLIC_API = "https://api.spinorio.com/api/v1"
11
+
12
+
13
+ def _url(value, *, internal=False):
14
+ value = value.rstrip("/")
15
+ parsed = urlsplit(value)
16
+ cluster_http = (
17
+ internal and parsed.scheme == "http"
18
+ and (parsed.hostname or "").endswith(".svc.cluster.local")
19
+ )
20
+ if (parsed.scheme != "https" and not cluster_http) or not parsed.netloc or any(
21
+ (parsed.username, parsed.password, parsed.query, parsed.fragment)
22
+ ):
23
+ raise ValueError("Use an HTTPS API URL without credentials, query or fragment")
24
+ return value
25
+
26
+
27
+ def _profile_token(base_url, profile):
28
+ import keyring
29
+
30
+ try:
31
+ return keyring.get_password("spinorio:" + base_url, profile)
32
+ except keyring.errors.KeyringError:
33
+ return None
34
+
35
+
36
+ class PersonalCredential:
37
+ kind = "api-token"
38
+
39
+ def __init__(self, token):
40
+ if not isinstance(token, str) or not token.strip():
41
+ raise ValueError("The API token must be a nonempty string")
42
+ self._token = token.strip()
43
+
44
+ def exchange(self, client):
45
+ return client._checked(client._http.post(
46
+ client.base_url + "/auth/token",
47
+ headers={"Authorization": "Bearer " + self._token},
48
+ json={"session_id": client.session_id},
49
+ )).json()
50
+
51
+
52
+ class LabCredential:
53
+ kind = "lab"
54
+
55
+ def __init__(self, base, path):
56
+ self.base = base
57
+ self.path = path
58
+
59
+ def exchange(self, client):
60
+ from filelock import FileLock
61
+
62
+ # Multiple kernels share the workspace credential. Rotate atomically and
63
+ # always reread it, including after another kernel refreshed the session.
64
+ with FileLock(str(self.path) + ".sdk.lock", timeout=30):
65
+ try:
66
+ token = self.path.read_text(encoding="utf-8").strip()
67
+ except OSError as exc:
68
+ raise RuntimeError("Lab credentials are unavailable. Restart your Lab session.") from exc
69
+ if not token:
70
+ raise ValueError("Lab credentials are empty. Restart your Lab session.")
71
+ data = client._checked(client._http.post(
72
+ self.base + "/v1/auth/sdk-token",
73
+ headers={"Authorization": "Bearer " + token},
74
+ json={"session_id": client.session_id},
75
+ )).json()
76
+ refresh = data.pop("refresh_token")
77
+ temporary = None
78
+ try:
79
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent, delete=False) as out:
80
+ temporary = Path(out.name)
81
+ os.chmod(temporary, 0o600)
82
+ out.write(refresh)
83
+ temporary.replace(self.path)
84
+ finally:
85
+ if temporary is not None and temporary.exists():
86
+ temporary.unlink()
87
+ return data
88
+
89
+
90
+ def resolve(token, base_url, profile):
91
+ """Explicit token, explicit profile, environment token, Lab, default profile."""
92
+ public = _url(base_url or os.getenv("SPINORIO_API_BASE_URL") or PUBLIC_API)
93
+ if token is not None:
94
+ return public, PersonalCredential(token)
95
+ if profile is not None:
96
+ value = _profile_token(public, profile)
97
+ if not value:
98
+ raise ValueError(f"No API token saved for profile {profile!r} at this API URL")
99
+ return public, PersonalCredential(value)
100
+ if "SPINORIO_API_TOKEN" in os.environ:
101
+ return public, PersonalCredential(os.environ["SPINORIO_API_TOKEN"])
102
+ if os.getenv("SPINORIO_SDK_LAB_MODE") == "1" and base_url is None and not os.getenv("SPINORIO_API_BASE_URL"):
103
+ base = os.getenv("SPINORIO_CONTROL_PLANE_BASE_URL", "").rstrip("/")
104
+ path = os.getenv("SPINORIO_SENSOR_REFRESH_TOKEN_PATH", "")
105
+ if not base or not path:
106
+ raise ValueError("Lab connection settings are unavailable. Restart your Lab session.")
107
+ base = _url(base, internal=True)
108
+ return base + "/api/v1", LabCredential(base, Path(path))
109
+ value = _profile_token(public, "default")
110
+ if not value:
111
+ raise ValueError("Supply token=, set SPINORIO_API_TOKEN, or save a profile with spinorio auth save")
112
+ return public, PersonalCredential(value)
spinorio/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ import argparse
2
+ import getpass
3
+ import sys
4
+
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(prog="spinorio")
8
+ parser.add_argument("--profile")
9
+ commands = parser.add_subparsers(dest="command", required=True)
10
+ auth = commands.add_parser("auth").add_subparsers(dest="action", required=True)
11
+ auth.add_parser("save")
12
+ auth.add_parser("remove")
13
+ commands.add_parser("usage")
14
+ commands.add_parser("engines")
15
+ wf = commands.add_parser("workflow").add_subparsers(dest="action", required=True)
16
+ run = wf.add_parser("run")
17
+ run.add_argument("script", nargs="?")
18
+ run.add_argument("args", nargs=argparse.REMAINDER)
19
+ wf.add_parser("enable-autostart")
20
+ wf.add_parser("disable-autostart")
21
+ args = parser.parse_args()
22
+ from . import Spinorio
23
+
24
+ if args.command == "auth":
25
+ import keyring, os
26
+
27
+ service = "spinorio:" + os.getenv(
28
+ "SPINORIO_API_BASE_URL", "https://api.spinorio.com/api/v1"
29
+ ).rstrip("/")
30
+ profile = args.profile or "default"
31
+ if args.action == "remove":
32
+ keyring.delete_password(service, profile)
33
+ else:
34
+ token = getpass.getpass("Spinorio API token (hidden): ")
35
+ with Spinorio(token) as client:
36
+ client.sensor_token()
37
+ keyring.set_password(service, profile, token)
38
+ print("Profile updated in the operating system credential store.")
39
+ return
40
+ with Spinorio(profile=args.profile) as client:
41
+ if args.command in {"usage", "engines"}:
42
+ import json
43
+
44
+ print(json.dumps(getattr(client, args.command)(), indent=2))
45
+ return
46
+ if args.action == "enable-autostart":
47
+ print(client.workflow.enable_autostart(profile=args.profile))
48
+ return
49
+ if args.action == "disable-autostart":
50
+ client.workflow.disable_autostart()
51
+ return
52
+ from IPython.terminal.interactiveshell import TerminalInteractiveShell
53
+
54
+ shell = TerminalInteractiveShell.instance()
55
+ client.workflow.run(ipython=shell)
56
+ try:
57
+ if args.script:
58
+ from pathlib import Path
59
+
60
+ sys.argv = [args.script] + args.args
61
+ result = shell.run_cell(
62
+ Path(args.script).read_text(encoding="utf-8"), store_history=True
63
+ )
64
+ client.workflow.flush()
65
+ if not result.success:
66
+ raise SystemExit(1)
67
+ else:
68
+ print(
69
+ "Spinorio capture is active in this IPython session. Existing kernels are unaffected."
70
+ )
71
+ shell.mainloop()
72
+ client.workflow.flush()
73
+ finally:
74
+ client.workflow.stop()
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
spinorio/client.py ADDED
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import threading
4
+ import time
5
+ import uuid
6
+ from urllib.parse import urlsplit
7
+ import httpx
8
+
9
+
10
+ class APIError(RuntimeError):
11
+ def __init__(self, status, detail, *, retry_after=None):
12
+ self.status_code = status
13
+ self.detail = detail
14
+ self.retry_after = retry_after
15
+ super().__init__(f"Spinorio API {status}: {detail}")
16
+
17
+
18
+ class SubmissionUncertain(RuntimeError):
19
+ """The server may have accepted this request. Retry this exact saved body."""
20
+
21
+ def __init__(self, client, path, body, factory):
22
+ self.client = client
23
+ self.path = path
24
+ self.request = dict(body)
25
+ self.factory = factory
26
+ self.idempotency_key = body["idempotency_key"]
27
+ super().__init__(
28
+ "Submission acknowledgement unavailable. Call exception.retry(); do not create a new job."
29
+ )
30
+
31
+ def retry(self):
32
+ return self.factory(
33
+ self.client.request("POST", self.path, json=self.request, retry=True)
34
+ )
35
+
36
+
37
+ class Spinorio:
38
+ """One authenticated client. Credentials never travel to artifact download hosts."""
39
+
40
+ def __init__(
41
+ self, token=None, *, base_url=None, profile=None, timeout=30, transport=None
42
+ ):
43
+ from ._credentials import resolve
44
+
45
+ self.base_url, self._credential = resolve(token, base_url, profile)
46
+ self._http = httpx.Client(
47
+ timeout=timeout, transport=transport, follow_redirects=False
48
+ )
49
+ self._lock = threading.Lock()
50
+ self._access = ""
51
+ self._expires = 0
52
+ self.identity = None
53
+ self.session_id = "sdk_" + uuid.uuid4().hex
54
+ from .compile import CompileClient
55
+ from .solvers import SolverClient
56
+ from .workflow import WorkflowClient
57
+
58
+ self.compile = CompileClient(self)
59
+ self.solvers = SolverClient(self)
60
+ self.workflow = WorkflowClient(self)
61
+
62
+ def __repr__(self):
63
+ return f"Spinorio(base_url={self.base_url!r})"
64
+
65
+ def close(self):
66
+ if getattr(self.workflow, "_owns_capture", False):
67
+ self.workflow.stop()
68
+ self._http.close()
69
+
70
+ def __enter__(self):
71
+ return self
72
+
73
+ def __exit__(self, *args):
74
+ self.close()
75
+
76
+ @staticmethod
77
+ def _checked(response):
78
+ if not 200 <= response.status_code < 300:
79
+ try:
80
+ detail = response.json().get("detail", "Request failed")
81
+ except ValueError:
82
+ detail = "Request failed"
83
+ raise APIError(
84
+ response.status_code,
85
+ detail,
86
+ retry_after=response.headers.get("retry-after"),
87
+ )
88
+ return response
89
+
90
+ def sensor_token(self):
91
+ with self._lock:
92
+ if time.monotonic() + 30 >= self._expires:
93
+ data = self._credential.exchange(self)
94
+ self._access = data["access_token"]
95
+ self._expires = time.monotonic() + data["expires_in"]
96
+ self.identity = {
97
+ k: data[k] for k in ("user_id", "workspace_id", "plan", "scopes")
98
+ }
99
+ return {
100
+ "access_token": self._access,
101
+ "expires_in": max(1, int(self._expires - time.monotonic())),
102
+ }
103
+
104
+ def request(
105
+ self, method, path, *, json=None, params=None, binary=False, retry=False
106
+ ):
107
+ if not path.startswith("/") or "://" in path or ".." in path:
108
+ raise ValueError("Invalid API path")
109
+ attempts = 3 if method == "GET" or retry else 1
110
+ for attempt in range(attempts):
111
+ try:
112
+ token = self.sensor_token()["access_token"]
113
+ response = self._http.request(
114
+ method,
115
+ self.base_url + path,
116
+ json=json,
117
+ params=params,
118
+ headers={"Authorization": "Bearer " + token},
119
+ )
120
+ if response.status_code == 401:
121
+ self._expires = 0
122
+ token = self.sensor_token()["access_token"]
123
+ response = self._http.request(
124
+ method,
125
+ self.base_url + path,
126
+ json=json,
127
+ params=params,
128
+ headers={"Authorization": "Bearer " + token},
129
+ )
130
+ self._checked(response)
131
+ return (
132
+ response.content
133
+ if binary
134
+ else (response.json() if response.content else None)
135
+ )
136
+ except APIError as exc:
137
+ if exc.status_code not in {502, 503, 504} or attempt + 1 == attempts:
138
+ raise
139
+ except httpx.TransportError:
140
+ if attempt + 1 == attempts:
141
+ raise
142
+ time.sleep(min(2**attempt, 4))
143
+
144
+ def upload(self, info, raw):
145
+ url = info["upload_url"]
146
+ if urlsplit(url).scheme != "https":
147
+ raise ValueError("Insecure artifact upload URL")
148
+ self._checked(
149
+ self._http.put(url, content=raw, headers=info.get("upload_headers", {}))
150
+ )
151
+
152
+ def download(self, info, destination):
153
+ from pathlib import Path
154
+ import tempfile
155
+
156
+ url = info["download_url"]
157
+ size = int(info["size_bytes"])
158
+ if urlsplit(url).scheme != "https" or size < 0 or size > 128 * 1024 * 1024:
159
+ raise ValueError("Invalid artifact download")
160
+ target = Path(destination)
161
+ temporary = None
162
+ try:
163
+ with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as out:
164
+ temporary = Path(out.name)
165
+ digest = hashlib.sha256()
166
+ total = 0
167
+ with self._http.stream("GET", url) as response:
168
+ self._checked(response)
169
+ for block in response.iter_bytes():
170
+ total += len(block)
171
+ if total > size:
172
+ raise ValueError("Artifact exceeds declared size")
173
+ digest.update(block)
174
+ out.write(block)
175
+ if total != size or digest.hexdigest() != info["sha256"]:
176
+ raise ValueError("Artifact integrity check failed")
177
+ temporary.replace(target)
178
+ return target
179
+ finally:
180
+ if temporary and temporary.exists():
181
+ temporary.unlink()
182
+
183
+ def engines(self):
184
+ return self.request("GET", "/sdk/engines")
185
+
186
+ def usage(self):
187
+ return self.request("GET", "/sdk/usage")
188
+
189
+ def qcompile(self, circuit, *, backend="linear_5", timeout=360, **kwargs):
190
+ return self.compile.run(circuit, target_id=backend, **kwargs).result(
191
+ wait=True, timeout=timeout
192
+ )
193
+
194
+ def provider_connection(self):
195
+ return self.request("GET", "/provider-connections/ibm")
196
+
197
+ def connect_ibm(self, api_token):
198
+ return self.request(
199
+ "POST", "/provider-connections/ibm", json={"api_key": api_token}
200
+ )
201
+
202
+ def disconnect_ibm(self):
203
+ return self.request("POST", "/provider-connections/ibm/disconnect", json={})
spinorio/compile.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import io
4
+ import time
5
+ import uuid
6
+ from urllib.parse import quote
7
+ import httpx
8
+ from .client import APIError, SubmissionUncertain
9
+
10
+
11
+ def segment(value):
12
+ if not isinstance(value, str) or not value or "/" in value or value in {".", ".."}:
13
+ raise ValueError("Invalid identifier")
14
+ return quote(value, safe=":")
15
+
16
+
17
+ def load_qpy(raw):
18
+ from qiskit import qpy
19
+
20
+ circuits = qpy.load(io.BytesIO(raw))
21
+ if len(circuits) != 1:
22
+ raise ValueError("Expected one Qiskit circuit")
23
+ return circuits[0]
24
+
25
+
26
+ class Compilation:
27
+ def __init__(self, client, job_id):
28
+ self.client = client
29
+ self.job_id = job_id
30
+ self.path = "/compiler/jobs/" + segment(job_id)
31
+
32
+ def status(self):
33
+ row = self.client.request("GET", self.path)
34
+ if row.get("engine") == "spinorio":
35
+ row["engine"] = "weylattice"
36
+ return row
37
+
38
+ def wait(self, timeout=360, poll_seconds=2):
39
+ if poll_seconds < 1:
40
+ raise ValueError("Polling interval must be at least one second")
41
+ deadline = time.monotonic() + timeout
42
+ while True:
43
+ state = self.status()
44
+ if state["status"] in {"completed", "failed"}:
45
+ return state
46
+ if time.monotonic() >= deadline:
47
+ raise TimeoutError("Compilation continues. Job ID: " + self.job_id)
48
+ time.sleep(poll_seconds)
49
+
50
+ def result(self, *, wait=False, timeout=360):
51
+ if wait:
52
+ state = self.wait(timeout)
53
+ if state["status"] == "failed":
54
+ raise RuntimeError(state.get("error") or "Compilation failed")
55
+ return load_qpy(
56
+ self.client.request("GET", self.path + "/artifact", binary=True)
57
+ )
58
+
59
+ def download(self, destination):
60
+ from pathlib import Path
61
+
62
+ target = Path(destination)
63
+ target.write_bytes(
64
+ self.client.request("GET", self.path + "/artifact", binary=True)
65
+ )
66
+ return target
67
+
68
+ def metrics(self):
69
+ state = self.status()
70
+ return {
71
+ key: value
72
+ for key, value in state.items()
73
+ if "metric" in key or key in {"quality", "analysis", "comparison"}
74
+ }
75
+
76
+
77
+ class CompileClient:
78
+ def __init__(self, client):
79
+ self.client = client
80
+
81
+ def list_engines(self):
82
+ return [e["id"] for e in self.client.engines()["compile"]["engines"]]
83
+
84
+ def get_default_engine(self):
85
+ return self.client.engines()["compile"]["default"]
86
+
87
+ def targets(self):
88
+ return self.client.request("GET", "/compiler/targets")
89
+
90
+ def ibm_instances(self):
91
+ return self.client.request("GET", "/compiler/ibm/instances")["instances"]
92
+
93
+ def ibm_backends(self, instance_id):
94
+ if not isinstance(instance_id, str) or not instance_id.startswith("ibmi_"):
95
+ raise ValueError("Choose an instance ID returned by client.compile.ibm_instances()")
96
+ segment(instance_id)
97
+ return self.client.request(
98
+ "GET", "/compiler/ibm/backends", params={"instance_id": instance_id}
99
+ )
100
+
101
+ def list(self, **filters):
102
+ return self.client.request("GET", "/compiler/jobs", params=filters)
103
+
104
+ def get(self, job_id):
105
+ return Compilation(self.client, job_id)
106
+
107
+ def upload_circuit(self, circuit, *, version_id=None):
108
+ from qiskit import QuantumCircuit, qpy, __version__
109
+
110
+ if not isinstance(circuit, QuantumCircuit):
111
+ raise TypeError("Expected a Qiskit QuantumCircuit")
112
+ buffer = io.BytesIO()
113
+ qpy.dump(circuit, buffer)
114
+ raw = buffer.getvalue()
115
+ if len(raw) > 16 * 1024 * 1024:
116
+ raise ValueError("Circuit QPY exceeds 16 MiB")
117
+ version_id = version_id or "cct_" + uuid.uuid4().hex + ":v1"
118
+ info = self.client.request(
119
+ "POST",
120
+ "/circuits/artifacts/uploads",
121
+ json={
122
+ "circuit_version_id": version_id,
123
+ "sha256": hashlib.sha256(raw).hexdigest(),
124
+ "size_bytes": len(raw),
125
+ "qiskit_version": __version__,
126
+ },
127
+ retry=True,
128
+ )
129
+ if info["status"] != "ready":
130
+ self.client.upload(info, raw)
131
+ self.client.request(
132
+ "POST",
133
+ "/circuits/artifacts/uploads/complete",
134
+ json={"circuit_version_id": version_id, "upload_id": info["upload_id"]},
135
+ retry=True,
136
+ )
137
+ return version_id
138
+
139
+ def run(
140
+ self,
141
+ circuit=None,
142
+ *,
143
+ circuit_version_id=None,
144
+ target_id="linear_5",
145
+ engine=None,
146
+ optimization_level=2,
147
+ idempotency_key=None,
148
+ **options,
149
+ ):
150
+ if (circuit is None) == (circuit_version_id is None):
151
+ raise ValueError("Supply exactly one circuit or circuit_version_id")
152
+ if engine not in (None, "weylattice"):
153
+ raise ValueError("Unknown engine; use compile.list_engines()")
154
+ key = idempotency_key or uuid.uuid4().hex
155
+ if circuit_version_id is None:
156
+ # Retry with the same key maps to the same immutable upload identity.
157
+ circuit_version_id = self.upload_circuit(
158
+ circuit,
159
+ version_id="cct_"
160
+ + hashlib.sha256(key.encode()).hexdigest()[:32]
161
+ + ":v1",
162
+ )
163
+ body = dict(
164
+ options,
165
+ circuit_version_id=circuit_version_id,
166
+ target_id=target_id,
167
+ engine=engine or "weylattice",
168
+ optimization_level=optimization_level,
169
+ idempotency_key=key,
170
+ )
171
+ factory = lambda row: self.get(row["job_id"])
172
+ try:
173
+ return factory(
174
+ self.client.request("POST", "/compiler/jobs", json=body, retry=True)
175
+ )
176
+ except (httpx.TransportError, APIError) as exc:
177
+ if isinstance(exc, APIError) and exc.status_code < 500:
178
+ raise
179
+ raise SubmissionUncertain(
180
+ self.client, "/compiler/jobs", body, factory
181
+ ) from exc
spinorio/solvers.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import json
4
+ import time
5
+ import uuid
6
+ import httpx
7
+ from .client import APIError, SubmissionUncertain
8
+ from .compile import segment
9
+
10
+ TERMINAL = {
11
+ "succeeded",
12
+ "failed",
13
+ "cancelled",
14
+ "deadline_exceeded",
15
+ "budget_exhausted",
16
+ "paused_calibration",
17
+ "needs_attention",
18
+ }
19
+
20
+
21
+ class SolverRun:
22
+ def __init__(self, client, run_id):
23
+ self.client = client
24
+ self.run_id = run_id
25
+ self.path = "/solvers/runs/" + segment(run_id)
26
+
27
+ def status(self):
28
+ return self.client.request("GET", self.path)
29
+
30
+ def wait(self, timeout=1800, poll_seconds=5):
31
+ if poll_seconds < 1:
32
+ raise ValueError("Polling interval must be at least one second")
33
+ deadline = time.monotonic() + timeout
34
+ while True:
35
+ state = self.status()
36
+ if state["status"] in TERMINAL:
37
+ return state
38
+ if time.monotonic() >= deadline:
39
+ raise TimeoutError("Solver continues. Run ID: " + self.run_id)
40
+ time.sleep(poll_seconds)
41
+
42
+ def result(self, *, wait=False, timeout=1800):
43
+ if wait:
44
+ self.wait(timeout)
45
+ return self.client.request("GET", self.path + "/result")
46
+
47
+ def cancel(self):
48
+ return self.client.request("POST", self.path + "/cancel", json={})
49
+
50
+ def resume(self, *, recalibrate=False):
51
+ return self.client.request(
52
+ "POST", self.path + "/resume", json={"recalibrate": recalibrate}
53
+ )
54
+
55
+ def events(self, after=0, limit=100):
56
+ return self.client.request(
57
+ "GET", self.path + "/events", params={"after": after, "limit": limit}
58
+ )
59
+
60
+ def artifacts(self, limit=100, offset=0):
61
+ return self.client.request(
62
+ "GET", self.path + "/artifacts", params={"limit": limit, "offset": offset}
63
+ )
64
+
65
+ def download(self, artifact_id, destination):
66
+ return self.client.download(
67
+ self.client.request(
68
+ "GET", self.path + "/artifacts/" + segment(artifact_id)
69
+ ),
70
+ destination,
71
+ )
72
+
73
+
74
+ class SolverClient:
75
+ def __init__(self, client):
76
+ self.client = client
77
+ self.optimization = self
78
+
79
+ def capabilities(self):
80
+ return self.client.request("GET", "/solvers/capabilities")
81
+
82
+ def list_engines(self):
83
+ return [e["id"] for e in self.client.engines()["solver"]["engines"]]
84
+
85
+ def get_default_engine(self):
86
+ return self.client.engines()["solver"]["default"]
87
+
88
+ def models(self, limit=50, offset=0):
89
+ return self.client.request(
90
+ "GET", "/solvers/models", params={"limit": limit, "offset": offset}
91
+ )
92
+
93
+ def remove_model(self, model_id):
94
+ return self.client.request(
95
+ "POST", "/solvers/models/" + segment(model_id) + "/remove", json={}
96
+ )
97
+
98
+ def list(self, limit=50, offset=0):
99
+ return self.client.request(
100
+ "GET", "/solvers/runs", params={"limit": limit, "offset": offset}
101
+ )
102
+
103
+ runs = list
104
+
105
+ def get(self, run_id):
106
+ return SolverRun(self.client, run_id)
107
+
108
+ def upload_model(self, problem, *, name="Optimization model", problem_type=None):
109
+ if hasattr(problem, "to_dict"):
110
+ problem = problem.to_dict()
111
+ if not isinstance(problem, dict):
112
+ raise TypeError("Expected an interchange model dictionary or BinaryModel")
113
+ problem = dict(problem)
114
+ if problem_type and problem.get("schema") == "qas.model.v1":
115
+ if problem.get("source") != problem_type:
116
+ raise ValueError("Conflicting problem types")
117
+ elif problem_type:
118
+ if problem.get("problem_type", problem_type) != problem_type:
119
+ raise ValueError("Conflicting problem types")
120
+ problem.setdefault("schema", "spinorio.solver.model.v1")
121
+ problem["problem_type"] = problem_type
122
+ raw = json.dumps(
123
+ problem, sort_keys=True, separators=(",", ":"), allow_nan=False
124
+ ).encode()
125
+ if len(raw) > 16 * 1024 * 1024:
126
+ raise ValueError("Model exceeds 16 MiB")
127
+ info = self.client.request(
128
+ "POST",
129
+ "/solvers/models/uploads",
130
+ json={
131
+ "name": name,
132
+ "sha256": hashlib.sha256(raw).hexdigest(),
133
+ "size_bytes": len(raw),
134
+ },
135
+ retry=True,
136
+ )
137
+ if info.get("status") != "ready":
138
+ self.client.upload(info, raw)
139
+ self.client.request(
140
+ "POST",
141
+ "/solvers/models/" + segment(info["model_id"]) + "/complete",
142
+ json={},
143
+ retry=True,
144
+ )
145
+ return info["model_id"]
146
+
147
+ def run(
148
+ self,
149
+ *,
150
+ problem=None,
151
+ model_id=None,
152
+ problem_type=None,
153
+ name="Optimization model",
154
+ engine=None,
155
+ idempotency_key=None,
156
+ **options,
157
+ ):
158
+ if (problem is None) == (model_id is None):
159
+ raise ValueError("Supply exactly one problem or model_id")
160
+ if engine not in (None, "hilvarian"):
161
+ raise ValueError("Unknown engine; use solvers.list_engines()")
162
+ if model_id is None:
163
+ model_id = self.upload_model(problem, name=name, problem_type=problem_type)
164
+ body = dict(
165
+ options,
166
+ model_id=model_id,
167
+ engine=engine or "hilvarian",
168
+ idempotency_key=idempotency_key or uuid.uuid4().hex,
169
+ )
170
+ factory = lambda row: self.get(row["run_id"])
171
+ try:
172
+ return factory(
173
+ self.client.request("POST", "/solvers/runs", json=body, retry=True)
174
+ )
175
+ except (httpx.TransportError, APIError) as exc:
176
+ if isinstance(exc, APIError) and exc.status_code < 500:
177
+ raise
178
+ raise SubmissionUncertain(
179
+ self.client, "/solvers/runs", body, factory
180
+ ) from exc
181
+
spinorio/workflow.py ADDED
@@ -0,0 +1,295 @@
1
+ """Kernel capture and retrieval through an authenticated Spinorio client."""
2
+
3
+ from __future__ import annotations
4
+ import hashlib
5
+ import time
6
+ from pathlib import Path
7
+ from .compile import segment, load_qpy
8
+
9
+
10
+ class Records:
11
+ def __init__(self, client, path):
12
+ self.client = client
13
+ self.path = path
14
+
15
+ def list(self, **filters):
16
+ return self.client.request("GET", self.path, params=filters)
17
+
18
+ def get(self, identifier):
19
+ return self.client.request("GET", self.path + "/" + segment(identifier))
20
+
21
+
22
+ class Jobs(Records):
23
+ def refresh(self, job_id):
24
+ return self.client.request(
25
+ "POST", "/provider-jobs/" + segment(job_id) + "/refresh", json={}
26
+ )
27
+
28
+ def list(self, **filters):
29
+ return self.client.request("GET", "/ui/jobs", params=filters)
30
+
31
+ def metrics(self, job_id):
32
+ return self.get(job_id).get("analysis")
33
+
34
+ def units(self, job_id, **pagination):
35
+ return self.client.request(
36
+ "GET", "/job-work-units/" + segment(job_id), params=pagination
37
+ )
38
+
39
+ def hardware(self, job_id):
40
+ return self.client.request("GET", "/job-hardware-context/" + segment(job_id))
41
+
42
+
43
+ class Circuits(Records):
44
+ def versions(self, circuit_id, **pagination):
45
+ return self.client.request(
46
+ "GET", "/circuits/" + segment(circuit_id) + "/versions", params=pagination
47
+ )
48
+
49
+ def version(self, version_id):
50
+ return self.client.request("GET", "/circuit-versions/" + segment(version_id))
51
+
52
+ def load(self, version_id):
53
+ return load_qpy(
54
+ self.client.request(
55
+ "GET", "/compiler/circuit-artifacts/" + segment(version_id), binary=True
56
+ )
57
+ )
58
+
59
+ def diagram(self, version_id):
60
+ return self.client.request("GET", "/circuit-diagrams/" + segment(version_id))
61
+
62
+ def draw(self, version_id, **options):
63
+ return self.load(version_id).draw(**options)
64
+
65
+ def metrics(self, version_id):
66
+ return self.client.request("GET", "/circuit-analyses/" + segment(version_id))
67
+
68
+
69
+ class WorkflowClient:
70
+ def __init__(self, client):
71
+ self.client = client
72
+ self.jobs = Jobs(client, "/jobs")
73
+ self.circuits = Circuits(client, "/circuits")
74
+ self.experiments = Records(client, "/experiments")
75
+ self.transpilations = Records(client, "/transpilations")
76
+
77
+ def run(
78
+ self,
79
+ *,
80
+ mode="automatic",
81
+ objects=None,
82
+ kinds=None,
83
+ include_code=False,
84
+ include_errors=False,
85
+ ipython=None,
86
+ resume_session_id=None,
87
+ ):
88
+ """Activate this kernel. Selective mode captures chosen namespace snapshots."""
89
+ from IPython import get_ipython
90
+ import _spinorio_runtime as runtime
91
+
92
+ shell = ipython or get_ipython()
93
+ if shell is None:
94
+ raise RuntimeError(
95
+ "Run this inside IPython/Jupyter, or use spinorio workflow run -- script.py"
96
+ )
97
+ if mode not in {"automatic", "selective"}:
98
+ raise ValueError("mode must be automatic or selective")
99
+ names = list(objects or [])
100
+ if any(
101
+ not isinstance(n, str) or not n.isidentifier() or n.startswith("_")
102
+ for n in names
103
+ ):
104
+ raise ValueError(
105
+ "objects must be public variable names in the active kernel"
106
+ )
107
+ types = list(kinds or ["circuits", "jobs", "results", "pass_managers"])
108
+ if not set(types) <= {"circuits", "jobs", "results", "pass_managers"}:
109
+ raise ValueError("Unsupported capture kind")
110
+ if mode == "selective" and not names:
111
+ raise ValueError("Selective capture requires at least one variable name")
112
+ if mode == "automatic" and (objects is not None or kinds is not None):
113
+ raise ValueError('Object/type filters require mode="selective"')
114
+ existing = _active.get(id(shell))
115
+ selection = (mode, tuple(sorted(names)), tuple(sorted(types)), bool(include_code), bool(include_errors))
116
+ if existing is not None:
117
+ if selection != existing._selection or (
118
+ resume_session_id is not None and resume_session_id != existing.client.session_id
119
+ ):
120
+ raise RuntimeError("Capture is active with different settings. Call client.workflow.stop() before changing them.")
121
+ self.client.session_id = existing.client.session_id
122
+ if self is not existing:
123
+ self.client._expires = 0
124
+ if resume_session_id is not None:
125
+ import re
126
+
127
+ if not re.fullmatch(r"sdk_[a-f0-9]{32}", resume_session_id):
128
+ raise ValueError("Invalid saved session ID")
129
+ self.client.session_id = resume_session_id
130
+ self.client._expires = 0
131
+ self.client.sensor_token()
132
+ identity = self.client.identity
133
+ if "workflow:write" not in identity["scopes"]:
134
+ raise PermissionError(
135
+ "This token needs Workflow write permission to capture"
136
+ )
137
+ if existing is not None:
138
+ owner = existing.client
139
+ if (self.client.base_url, identity["user_id"], identity["workspace_id"]) != (
140
+ owner.base_url, owner.identity["user_id"], owner.identity["workspace_id"]
141
+ ):
142
+ raise RuntimeError("This kernel is capturing for another account or API URL. Stop capture before switching accounts.")
143
+ self._shell = shell
144
+ return runtime.status(shell)
145
+ if runtime.status(shell).get("installed"):
146
+ raise RuntimeError("A sensor is already active outside this client. Stop it before starting capture.")
147
+ key = hashlib.sha256(
148
+ (
149
+ self.client.base_url
150
+ + "\0"
151
+ + identity["user_id"]
152
+ + "\0"
153
+ + identity["workspace_id"]
154
+ ).encode()
155
+ ).hexdigest()[:32]
156
+ # Each process has its own active queue; session id persists in its directory
157
+ # for CLI replay after a crash. Never mix accounts or API origins.
158
+ root = Path.home() / ".spinorio" / "capture" / key / self.client.session_id
159
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
160
+ # Lock an active spool so two kernels cannot consume the same queue.
161
+ from filelock import FileLock, Timeout
162
+
163
+ lock = FileLock(str(root / "capture.lock"))
164
+ try:
165
+ lock.acquire(timeout=0)
166
+ except Timeout as exc:
167
+ raise RuntimeError("This saved capture session is already active") from exc
168
+ config = {
169
+ "endpoint": self.client.base_url + "/observations",
170
+ "token_endpoint": self.client.base_url + "/auth/token",
171
+ "bootstrap_refresh_token": "",
172
+ "refresh_token_path": str(root / "unused-refresh"),
173
+ "spool_dir": str(root / "pending"),
174
+ "dead_letter_dir": str(root / "dead-letter"),
175
+ "sender_state_path": str(root / "state.json"),
176
+ "qpy_spool_dir": str(root / "qpy"),
177
+ "qpy_upload_endpoint": self.client.base_url + "/circuits/artifacts/uploads",
178
+ "qpy_capture_enabled": True,
179
+ "token_provider": self.client.sensor_token,
180
+ "include_cell_code": include_code,
181
+ "include_errors": include_errors,
182
+ "capture_mode": mode,
183
+ "capture_names": names,
184
+ "capture_kinds": types,
185
+ "http_timeout": 15.0,
186
+ "enabled": True,
187
+ "dev_jsonl_path": "",
188
+ }
189
+ try:
190
+ if not runtime.install(shell, config):
191
+ raise RuntimeError("Sensor could not register IPython hooks")
192
+ except BaseException:
193
+ lock.release()
194
+ raise
195
+ _active[id(shell)] = self
196
+ self._spool_lock = lock
197
+ self._shell = shell
198
+ self._selection = selection
199
+ self._owns_capture = True
200
+ return runtime.status(shell)
201
+
202
+ start = run
203
+
204
+ def status(self):
205
+ import _spinorio_runtime as runtime
206
+ from IPython import get_ipython
207
+
208
+ return runtime.status(getattr(self, "_shell", None) or get_ipython())
209
+
210
+ def stop(self):
211
+ import _spinorio_runtime as runtime
212
+ from IPython import get_ipython
213
+
214
+ shell = getattr(self, "_shell", None) or get_ipython()
215
+ result = runtime.uninstall(shell)
216
+ owner = _active.pop(id(shell), None)
217
+ if owner is not None:
218
+ owner._spool_lock.release()
219
+ owner._owns_capture = False
220
+ owner._shell = None
221
+ self._shell = None
222
+ return result
223
+
224
+ def flush(self, timeout=30):
225
+ deadline = time.monotonic() + timeout
226
+ while True:
227
+ state = self.status()
228
+ if not state.get("installed"):
229
+ return state
230
+ sender = state.get("sender", {})
231
+ qpy = state.get("qpy", {})
232
+ if (
233
+ not sender.get("pending_batches", 0)
234
+ and not sender.get("pending_detail_batches", 0)
235
+ and not qpy.get("queued", 0)
236
+ ):
237
+ return state
238
+ if time.monotonic() >= deadline:
239
+ raise TimeoutError(
240
+ "Capture remains on disk. Use workflow.status() to inspect delivery."
241
+ )
242
+ time.sleep(0.2)
243
+
244
+ def pending_sessions(self):
245
+ """List resumable spool session IDs for this account and API origin."""
246
+ self.client.sensor_token()
247
+ identity = self.client.identity
248
+ key = hashlib.sha256(
249
+ (
250
+ self.client.base_url
251
+ + "\0"
252
+ + identity["user_id"]
253
+ + "\0"
254
+ + identity["workspace_id"]
255
+ ).encode()
256
+ ).hexdigest()[:32]
257
+ base = Path.home() / ".spinorio" / "capture" / key
258
+ return [
259
+ {
260
+ "session_id": folder.name,
261
+ "pending_files": len(list((folder / "pending").glob("*.json"))),
262
+ "qpy_files": len(list((folder / "qpy").glob("*.qpy"))),
263
+ }
264
+ for folder in sorted(base.glob("sdk_*"))
265
+ if folder.is_dir()
266
+ ]
267
+
268
+ def enable_autostart(self, *, profile=None):
269
+ """Explicit opt-in for future kernels in the current IPython profile."""
270
+ from IPython.paths import get_ipython_dir
271
+
272
+ target = (
273
+ Path(get_ipython_dir()) / "profile_default" / "startup" / "90-spinorio.py"
274
+ )
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ code = (
277
+ "from spinorio import Spinorio\n_spinorio_capture = Spinorio(profile="
278
+ + repr(profile)
279
+ + ")\n_spinorio_capture.workflow.run()\n"
280
+ )
281
+ target.write_text(code, encoding="utf-8")
282
+ return target
283
+
284
+ def disable_autostart(self):
285
+ from IPython.paths import get_ipython_dir
286
+
287
+ target = (
288
+ Path(get_ipython_dir()) / "profile_default" / "startup" / "90-spinorio.py"
289
+ )
290
+ if target.exists():
291
+ target.unlink()
292
+
293
+
294
+
295
+ _active = {}
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: spinorio
3
+ Version: 1.1.0
4
+ Summary: Spinorio Workflow, Weylattice compilation and Hilvarian optimization client
5
+ Author: Spinorio Inc
6
+ License: Proprietary
7
+ Requires-Python: <3.14,>=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: httpx<1,>=0.27
11
+ Requires-Dist: spinorio-runtime==1.1.0
12
+ Requires-Dist: ipython>=8.18
13
+ Requires-Dist: keyring<27,>=25
14
+ Requires-Dist: filelock<4,>=3.16
15
+ Provides-Extra: qiskit
16
+ Requires-Dist: qiskit<2.6,>=2.5; extra == "qiskit"
17
+ Dynamic: license-file
18
+
19
+ # Spinorio Python SDK
20
+
21
+ Workflow capture and retrieval, Weylattice compilation, and Hilvarian optimization from your own Python environment.
22
+
23
+ Install Spinorio 1.1.0:
24
+
25
+ ```bash
26
+ python -m pip install 'spinorio[qiskit]==1.1.0'
27
+ ```
28
+
29
+ `spinorio-runtime` is a **required dependency**, including when installing plain `spinorio`. The native sensor is installed by default. The `qiskit` extra installs the supported Qiskit 2.5 series for circuit helpers and workflow capture; it does not control sensor installation. Importing the package does not start capture.
30
+
31
+ Supported environments: CPython 3.12 and 3.13, Windows x64, Linux x64 with glibc, and macOS Intel/Apple Silicon. Python 3.14, PyPy, Windows ARM and Linux ARM are not declared supported in this release.
32
+
33
+ ## Authentication
34
+
35
+ Create an expiring token at **Account → API tokens** on spinorio.com. Copy it when shown. You can list token metadata and revoke it later; the full secret cannot be retrieved again.
36
+
37
+ ```python
38
+ import os
39
+ from spinorio import Spinorio
40
+
41
+ client = Spinorio(token=os.environ['SPINORIO_API_TOKEN'])
42
+ print(client.usage())
43
+ ```
44
+
45
+ Inside Spinorio Lab, use the same client without copying a token:
46
+
47
+ ```python
48
+ from spinorio import Spinorio
49
+ client = Spinorio()
50
+ ```
51
+
52
+ The client reuses your authenticated Lab workspace. On your laptop, `Spinorio()` reads `SPINORIO_API_TOKEN` automatically. You can also use an operating-system credential store:
53
+
54
+ ```bash
55
+ spinorio auth save
56
+ spinorio usage
57
+ ```
58
+
59
+ The prompt hides your token. `spinorio --profile research auth save` and `Spinorio(profile='research')` select a named profile. Credential selection is explicit token, explicit named profile, environment token, authenticated Lab workspace, then default saved profile. An invalid explicit credential raises an error; the client never switches accounts silently. No browser login is required. On a headless system without a working keyring, pass the token or use the environment variable. Never place a real token in a shared notebook or Git repository.
60
+
61
+ Tokens authenticate to `https://api.spinorio.com/api/v1`; the client exchanges them for short-lived access tokens. Different client instances keep separate credentials. Revocation rejects subsequent requests, including requests with an unexpired access token. Accepted jobs continue after revocation. Plan changes are reflected without replacing the personal token; the entitlement cache lasts at most 30 seconds.
62
+
63
+ ## Compile
64
+
65
+ ```python
66
+ from qiskit import QuantumCircuit
67
+
68
+ qc = QuantumCircuit(2)
69
+ qc.h(0)
70
+ qc.cx(0, 1)
71
+
72
+ print(client.compile.list_engines()) # ['weylattice']
73
+ print(client.compile.get_default_engine()) # 'weylattice'
74
+ print(client.compile.targets())
75
+
76
+ job = client.compile.run(qc, target_id='linear_5')
77
+ print(job.job_id)
78
+ print(job.status())
79
+ compiled = job.result(wait=True, timeout=360)
80
+ print(job.metrics())
81
+ job.download('compiled.qpy')
82
+
83
+ # Reconnect to existing work, including from another machine:
84
+ job = client.compile.get(job.job_id)
85
+
86
+ # Synchronous convenience:
87
+ compiled = client.qcompile(qc, backend='linear_5')
88
+ ```
89
+
90
+ `linear_5` is a reference topology, not a physical QPU. Engine selection is optional: `engine='weylattice'` is the default. Unsupported engines are rejected. Fetch the catalog again to discover later additions.
91
+
92
+ For IBM targets, connect your provider account first, select an instance using `client.compile.ibm_instances()`, then select a returned target from `client.compile.ibm_backends(instance_id)`. Existing provider connections from Lab are reused. A token with `providers:manage` can call `client.connect_ibm(api_token)`, `client.provider_connection()` and `client.disconnect_ibm()`. Provider credentials are stored by the existing encrypted control-plane path and are never returned through the public API. An API subscription does not include provider QPU charges.
93
+
94
+ ## Solve
95
+
96
+ ```python
97
+ print(client.solvers.list_engines()) # ['hilvarian']
98
+ print(client.solvers.get_default_engine()) # 'hilvarian'
99
+ print(client.solvers.capabilities())
100
+
101
+ problem = {
102
+ 'schema': 'spinorio.solver.model.v1',
103
+ 'problem_type': 'maxcut',
104
+ 'variables': ['a', 'b', 'c'],
105
+ 'edges': [['a', 'b', 1], ['b', 'c', 1], ['c', 'a', 1]],
106
+ }
107
+ run = client.solvers.optimization.run(
108
+ problem=problem, problem_type='maxcut', executor='local_numpy',
109
+ engine='hilvarian',
110
+ )
111
+ print(run.run_id, run.status())
112
+ state = run.wait(timeout=300)
113
+ result = run.result()
114
+ print(run.events())
115
+ for item in run.artifacts():
116
+ print(item)
117
+
118
+ same_run = client.solvers.get(run.run_id)
119
+ # same_run.cancel()
120
+ # same_run.resume(recalibrate=True)
121
+ # same_run.download(artifact_id, 'evidence.json')
122
+ ```
123
+
124
+ `local_numpy` and `local_aer` are server-side executors. Availability comes from `capabilities()`. IBM execution retains the existing per-account hardware gate. No automatic escalation to paid hardware occurs. Search, error-management and budget parameters use the existing solver contract. Basic/Plus API admissions also enforce per-run wall-time and shot ceilings. `result()` reports an error when no result exists; `wait()` can also return a paused or failed state.
125
+
126
+ For uncertain submission acknowledgements, retain the exception and retry its exact request:
127
+
128
+ ```python
129
+ from spinorio import SubmissionUncertain
130
+ try:
131
+ run = client.solvers.run(model_id=saved_model_id, idempotency_key='my-experiment-001')
132
+ except SubmissionUncertain as error:
133
+ run = error.retry()
134
+ ```
135
+
136
+ The retry retains the originating client, model and idempotency key. Changing request settings with the same key returns a conflict. Ordinary reads, polls and idempotent job retries do not consume another job allowance. Keep your returned job/run IDs.
137
+
138
+ ## Workflow in your existing notebook
139
+
140
+ ```python
141
+ client.workflow.run() # current IPython kernel only
142
+ # Run your quantum experiment in following cells.
143
+ client.workflow.status() # sender, native observer and QPY queue health
144
+ client.workflow.flush(timeout=30) # wait for pending uploads
145
+ client.workflow.stop() # stop hooks; unsent work stays on disk
146
+ ```
147
+
148
+ Spinorio Lab starts automatic capture when its kernel starts. Calling `client.workflow.run()` again with the same account and settings reuses that sensor. To change capture settings, call `client.workflow.stop()` first, then `client.workflow.run(...)`. On your laptop, capture starts only when you call `run()` or explicitly enable autostart.
149
+
150
+ Automatic mode uses the existing Qiskit observation adapters: circuits and versions, supported transpilation boundaries, provider jobs and already-returned results. It is not a recorder of every Python object, arbitrary file or complete kernel memory. A completed experiment cannot be reconstructed if it was never observed or its QPY capture failed. Use `status()` to inspect dropped, rejected, pending and failed delivery counts.
151
+
152
+ Selective mode is deliberately narrower: it takes supported named-object snapshots at cell boundaries and disables global runtime-boundary capture:
153
+
154
+ ```python
155
+ client.workflow.run(
156
+ mode='selective', objects=['qc', 'job', 'result'],
157
+ kinds=['circuits', 'jobs', 'results'],
158
+ )
159
+ ```
160
+
161
+ Names must exist in the active kernel when the observed cell finishes. Direct QuantumCircuit, supported job/result and pass-manager objects are eligible. Selective mode does not recursively capture arbitrary containers or full intermediate call histories. Raw cell code and exception messages are off by default; opt in with `include_code=True` or `include_errors=True`.
162
+
163
+ Queues are bounded and stored under `~/.spinorio/capture/`, partitioned by account, API origin and sensor session. They contain experiment data, not your API token. Delivery retries use persisted payloads. After a kernel crash:
164
+
165
+ ```python
166
+ print(client.workflow.pending_sessions())
167
+ client.workflow.run(resume_session_id='sdk_...') # choose a returned session ID
168
+ ```
169
+
170
+ Only one kernel may use a given spool at a time. Retrying an unchanged payload does not double-charge ingress bytes. Queue overflow is reported rather than silently accepting data. Revocation or exhausted allowance can interrupt delivery, so preserve pending queues until the issue is resolved.
171
+
172
+ From a terminal:
173
+
174
+ ```bash
175
+ spinorio workflow run # opens a new captured IPython session
176
+ spinorio workflow run -- experiment.py # executes a script in captured IPython
177
+ spinorio workflow enable-autostart # explicitly enable future default-profile kernels
178
+ spinorio workflow disable-autostart
179
+ ```
180
+
181
+ The CLI cannot attach to a different, already-running kernel. Use the Python call inside that kernel. Autostart is optional; installation and ordinary `run()` do not edit IPython startup files. For scripts, runtime boundaries are captured during the script; namespace snapshots occur at the script's end.
182
+
183
+ ## Recall and analysis
184
+
185
+ ```python
186
+ jobs = client.workflow.jobs.list(limit=50)
187
+ record = client.workflow.jobs.get(job_id)
188
+ analysis = client.workflow.jobs.metrics(job_id)
189
+ # client.workflow.jobs.refresh(job_id) # refresh supported provider jobs using your stored connection
190
+ units = client.workflow.jobs.units(job_id, limit=50, offset=0)
191
+
192
+ circuits = client.workflow.circuits.list(limit=50)
193
+ versions = client.workflow.circuits.versions(circuit_id)
194
+ snapshot = client.workflow.circuits.version(circuit_version_id)
195
+ qc = client.workflow.circuits.load(circuit_version_id)
196
+ diagram_data = client.workflow.circuits.diagram(circuit_version_id)
197
+ print(client.workflow.circuits.draw(circuit_version_id, output='text'))
198
+ metrics = client.workflow.circuits.metrics(circuit_version_id)
199
+ ```
200
+
201
+ `load()` returns a Qiskit circuit from stored QPY. `diagram()` returns the control plane's structured diagram data; `draw()` renders locally using Qiskit. Metrics are the control plane's available analyses, not invented values. Missing artifacts return a clear API error. Nothing unpickles arbitrary remote Python objects.
202
+
203
+ ## Account usage
204
+
205
+ `client.usage()` returns your current plan, monthly limits and usage. Basic includes 10 compilation jobs and 10 solver runs per month; Plus includes 200 of each. Workflow capture is measured by uploaded data, with 10 MiB per month on Basic and 1 GiB on Plus. Reads and polling do not use another job allowance. Upload and solver execution limits are also returned by `usage()`.
206
+
207
+ Usage belongs to your account across Lab, laptops and API tokens. Creating another token does not reset it. Lab session time is a separate allowance. If an allowance is exhausted, the API returns HTTP 429; `APIError` exposes `status_code`, `detail` and `retry_after`. Check usage or upgrade through your account.
@@ -0,0 +1,13 @@
1
+ spinorio/__init__.py,sha256=4a9Qm9VMHEaAzw472MPbvTOw6AnOaHGqmxlGh6N3cJA,224
2
+ spinorio/_credentials.py,sha256=xjgfNcTdwIq_TD66YzYQVHlJsjqxwRjLYXI4RpMLhEg,4381
3
+ spinorio/cli.py,sha256=huZGSq-1IQk43FdqVn4B2pcr85FBJ8hFcoMQPGF3CmE,2793
4
+ spinorio/client.py,sha256=5fQPVwnchJoNlKA05E7MMpg8rV1hXfRWwH_d4YneJc4,7225
5
+ spinorio/compile.py,sha256=a-NraVIPJglpPqa-JtvHmE1ByIWL9z9jSwLSjOVj1P4,6112
6
+ spinorio/solvers.py,sha256=UPo9qtB2gq9i46TBRiUlb555ew-kPq_-qG5mJKGXCx4,5961
7
+ spinorio/workflow.py,sha256=2MN2Jbw2CIXNvEw7NCA_4SC-1Q49WYhfuuegybfvVLM,11080
8
+ spinorio-1.1.0.dist-info/licenses/LICENSE,sha256=6KqGnUmyeWSLZNwajEZM2gjA1hSaisrn1nOzSxFus4Q,190
9
+ spinorio-1.1.0.dist-info/METADATA,sha256=s1Qx_XBvxg9FiPtPT7977Xtq1GAskUQFoagnKoTWe0A,11088
10
+ spinorio-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ spinorio-1.1.0.dist-info/entry_points.txt,sha256=dDVoYFZ-Q7LhTFoFBbiFsQcW1_R1XB5H4tp1LIZ9qJk,47
12
+ spinorio-1.1.0.dist-info/top_level.txt,sha256=2OVrUpNvV9G1Qfb6PcId8WAAnZmHBKSpv6xV9lEjXx0,9
13
+ spinorio-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spinorio = spinorio.cli:main
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2026 Spinorio Inc. All rights reserved.
2
+
3
+ Proprietary software. No open-source license is granted by this distribution.
4
+ Use is subject to the applicable Spinorio service terms.
@@ -0,0 +1 @@
1
+ spinorio