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 +6 -0
- spinorio/_credentials.py +112 -0
- spinorio/cli.py +78 -0
- spinorio/client.py +203 -0
- spinorio/compile.py +181 -0
- spinorio/solvers.py +181 -0
- spinorio/workflow.py +295 -0
- spinorio-1.1.0.dist-info/METADATA +207 -0
- spinorio-1.1.0.dist-info/RECORD +13 -0
- spinorio-1.1.0.dist-info/WHEEL +5 -0
- spinorio-1.1.0.dist-info/entry_points.txt +2 -0
- spinorio-1.1.0.dist-info/licenses/LICENSE +4 -0
- spinorio-1.1.0.dist-info/top_level.txt +1 -0
spinorio/__init__.py
ADDED
spinorio/_credentials.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
spinorio
|