qpi-client 0.0.3__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.
- qpi_client/__init__.py +38 -0
- qpi_client/client.py +496 -0
- qpi_client/provider.py +366 -0
- qpi_client-0.0.3.dist-info/METADATA +11 -0
- qpi_client-0.0.3.dist-info/RECORD +7 -0
- qpi_client-0.0.3.dist-info/WHEEL +5 -0
- qpi_client-0.0.3.dist-info/top_level.txt +1 -0
qpi_client/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""QPI Client SDK for Python.
|
|
2
|
+
|
|
3
|
+
A client library for interacting with the QPI quantum computing platform.
|
|
4
|
+
Provides both a low-level HTTP client and Qiskit-compatible backend/job classes.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from qpi_client import QPIClient
|
|
9
|
+
|
|
10
|
+
# Low-level client
|
|
11
|
+
client = QPIClient("http://localhost:8090", api_token="my-token")
|
|
12
|
+
job_id = client.submit_job([{"circuit": "OPENQASM 3.0; ..."}])
|
|
13
|
+
|
|
14
|
+
# Qiskit integration (preferred)
|
|
15
|
+
backend = client.get_backend(name="mock")
|
|
16
|
+
job = backend.run(circuit=my_circuit, shots=1024)
|
|
17
|
+
result = job.result()
|
|
18
|
+
|
|
19
|
+
# Or submit raw QASM
|
|
20
|
+
job = backend.run(qasm="OPENQASM 3.0; ...", params=[[0.5]])
|
|
21
|
+
result = job.result()
|
|
22
|
+
|
|
23
|
+
# Retrieve a past job
|
|
24
|
+
past_job = client.job(job_id)
|
|
25
|
+
past_job = backend.job(job_id)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import importlib.metadata
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = importlib.metadata.version("qpi-client")
|
|
32
|
+
except importlib.metadata.PackageNotFoundError:
|
|
33
|
+
__version__ = "0.0.3"
|
|
34
|
+
|
|
35
|
+
from qpi_client.client import QPIClient
|
|
36
|
+
from qpi_client.provider import QPIBackend, QPIJob
|
|
37
|
+
|
|
38
|
+
__all__ = ["__version__", "QPIClient", "QPIBackend", "QPIJob"]
|
qpi_client/client.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Low-level HTTP client for the QPI orchestrator REST API.
|
|
2
|
+
|
|
3
|
+
This module provides :class:`QPIClient`, a thin wrapper around ``requests.Session``
|
|
4
|
+
that handles authentication and serialisation for every QPI API endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from qpi_client.provider import QPIBackend, QPIJob
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class QPIClient:
|
|
18
|
+
"""Low-level HTTP wrapper for the QPI orchestrator API.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
base_url: Root URL of the QPI orchestrator (e.g. ``"http://localhost:8090"``).
|
|
22
|
+
api_token: Optional API token used for authentication via the
|
|
23
|
+
``X-API-Token`` header. When *None*, no token header is sent
|
|
24
|
+
(useful for cookie/JWT-based auth in browser contexts).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, base_url: str, api_token: str | None = None) -> None:
|
|
28
|
+
self.base_url: str = base_url.rstrip("/")
|
|
29
|
+
self.api_token: str | None = api_token
|
|
30
|
+
self._session: requests.Session = requests.Session()
|
|
31
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
32
|
+
if api_token:
|
|
33
|
+
self._session.headers["X-API-Token"] = api_token
|
|
34
|
+
|
|
35
|
+
# -- public API ----------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def submit_job(
|
|
38
|
+
self,
|
|
39
|
+
circuits: list[dict[str, Any]],
|
|
40
|
+
shots: int = 1024,
|
|
41
|
+
meas_level: int = 2,
|
|
42
|
+
meas_return: str = "single",
|
|
43
|
+
qpu_target: str = "",
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Submit a quantum job to the orchestrator.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
circuits: A list of circuit payload dicts. Each dict **must**
|
|
49
|
+
contain a ``"circuit"`` key whose value is an OpenQASM 3
|
|
50
|
+
string. Optional keys: ``"parameter_values"``, ``"shots"``.
|
|
51
|
+
shots: Default number of shots for every circuit.
|
|
52
|
+
meas_level: Measurement level (``2`` = classified bits).
|
|
53
|
+
meas_return: ``"single"`` or ``"avg"``.
|
|
54
|
+
qpu_target: Optional QPU routing hint.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The server-assigned job ID as a string.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
requests.HTTPError: If the server returns a non-2xx status.
|
|
61
|
+
"""
|
|
62
|
+
payload: dict[str, Any] = {
|
|
63
|
+
"circuits": circuits,
|
|
64
|
+
"shots": shots,
|
|
65
|
+
"meas_level": meas_level,
|
|
66
|
+
"meas_return": meas_return,
|
|
67
|
+
}
|
|
68
|
+
if qpu_target:
|
|
69
|
+
payload["qpu_target"] = qpu_target
|
|
70
|
+
|
|
71
|
+
resp = self._session.post(f"{self.base_url}/api/jobs", json=payload)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
data = resp.json()
|
|
74
|
+
|
|
75
|
+
# The orchestrator may return the ID at the top level or nested.
|
|
76
|
+
job_id: str = data.get("id") or data.get("job_id", "")
|
|
77
|
+
if not job_id:
|
|
78
|
+
raise ValueError(f"Server response did not contain a job ID: {data!r}")
|
|
79
|
+
return job_id
|
|
80
|
+
|
|
81
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
82
|
+
"""Retrieve full details for *job_id*.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A dict with at least ``"id"``, ``"status"``, ``"payload"``,
|
|
86
|
+
``"results"``, ``"created"``, and ``"updated"`` keys.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
requests.HTTPError: If the server returns a non-2xx status.
|
|
90
|
+
"""
|
|
91
|
+
resp = self._session.get(f"{self.base_url}/api/jobs/{job_id}")
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
return resp.json()
|
|
94
|
+
|
|
95
|
+
def list_jobs(self) -> list[dict[str, Any]]:
|
|
96
|
+
"""List all jobs belonging to the authenticated user.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A list of job-record dicts.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
requests.HTTPError: If the server returns a non-2xx status.
|
|
103
|
+
"""
|
|
104
|
+
resp = self._session.get(f"{self.base_url}/api/jobs")
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
data = resp.json()
|
|
107
|
+
# The response might be a bare list or wrapped in {"jobs": [...]}.
|
|
108
|
+
if isinstance(data, list):
|
|
109
|
+
return data
|
|
110
|
+
return data.get("jobs", [])
|
|
111
|
+
|
|
112
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
113
|
+
"""Request cancellation of *job_id*.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The updated job-record dict.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
requests.HTTPError: If the server returns a non-2xx status.
|
|
120
|
+
"""
|
|
121
|
+
resp = self._session.post(f"{self.base_url}/api/jobs/{job_id}/cancel")
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
return resp.json()
|
|
124
|
+
|
|
125
|
+
# -- high-level helpers --------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def get_backend(self, name: str = "qpi") -> "QPIBackend":
|
|
128
|
+
"""Return a :class:`QPIBackend` handle for the named QPU.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name: Backend / QPU name (e.g. ``"mock"``, ``"qiskit_aer"``).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A configured :class:`QPIBackend` instance bound to this client.
|
|
135
|
+
"""
|
|
136
|
+
from qpi_client.provider import QPIBackend
|
|
137
|
+
|
|
138
|
+
return QPIBackend(self, name=name)
|
|
139
|
+
|
|
140
|
+
def job(self, job_id: str) -> "QPIJob":
|
|
141
|
+
"""Retrieve an existing job by ID.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
job_id: The server-assigned job ID.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A :class:`QPIJob` handle (backend will be *None*).
|
|
148
|
+
"""
|
|
149
|
+
from qpi_client.provider import QPIJob
|
|
150
|
+
|
|
151
|
+
return QPIJob(backend=None, job_id=job_id, client=self)
|
|
152
|
+
|
|
153
|
+
# -- QPU discovery -------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def list_qpus(self) -> list[dict[str, Any]]:
|
|
156
|
+
"""List all online QPUs.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A list of QPU record dicts.
|
|
160
|
+
"""
|
|
161
|
+
resp = self._session.get(f"{self.base_url}/api/qpus")
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
def get_qpu(self, name: str) -> dict[str, Any]:
|
|
166
|
+
"""Retrieve a single QPU by name.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
name: The QPU's unique name.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A QPU record dict.
|
|
173
|
+
"""
|
|
174
|
+
resp = self._session.get(f"{self.base_url}/api/qpus/{name}")
|
|
175
|
+
resp.raise_for_status()
|
|
176
|
+
return resp.json()
|
|
177
|
+
|
|
178
|
+
# -- QPU Registry & Toggles (admin-only) ---------------------------------
|
|
179
|
+
|
|
180
|
+
def create_qpu(
|
|
181
|
+
self,
|
|
182
|
+
name: str,
|
|
183
|
+
executor_type: str | None = None,
|
|
184
|
+
num_qubits: int | None = None,
|
|
185
|
+
enabled: bool | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Create a new QPU record (admin-only).
|
|
188
|
+
|
|
189
|
+
The server generates a random ``access_token`` and returns it in
|
|
190
|
+
plain text exactly once; only the hash is persisted.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
name: QPU name.
|
|
194
|
+
executor_type: Type of executor.
|
|
195
|
+
num_qubits: Number of qubits on the device.
|
|
196
|
+
enabled: Whether the QPU should be enabled (default ``True``).
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A dict containing at least ``id``, ``name``, ``access_token``,
|
|
200
|
+
``executor_type``, ``status``, and ``enabled``.
|
|
201
|
+
"""
|
|
202
|
+
payload: dict[str, Any] = {"name": name}
|
|
203
|
+
if executor_type is not None:
|
|
204
|
+
payload["executor_type"] = executor_type
|
|
205
|
+
if num_qubits is not None:
|
|
206
|
+
payload["num_qubits"] = num_qubits
|
|
207
|
+
if enabled is not None:
|
|
208
|
+
payload["enabled"] = enabled
|
|
209
|
+
|
|
210
|
+
resp = self._session.post(f"{self.base_url}/api/op/qpus/create", json=payload)
|
|
211
|
+
resp.raise_for_status()
|
|
212
|
+
return resp.json()
|
|
213
|
+
|
|
214
|
+
def connect_qpu(
|
|
215
|
+
self,
|
|
216
|
+
name: str,
|
|
217
|
+
access_token: str,
|
|
218
|
+
executor_type: str | None = None,
|
|
219
|
+
device_config: dict[str, Any] | None = None,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Connect a QPU driver node.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
name: QPU name.
|
|
225
|
+
access_token: The access token for the QPU.
|
|
226
|
+
executor_type: Type of executor.
|
|
227
|
+
device_config: Configuration dict for the device.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
The connection response dict with NNG port assignments.
|
|
231
|
+
"""
|
|
232
|
+
payload: dict[str, Any] = {
|
|
233
|
+
"name": name,
|
|
234
|
+
"access_token": access_token,
|
|
235
|
+
}
|
|
236
|
+
if executor_type is not None:
|
|
237
|
+
payload["executor_type"] = executor_type
|
|
238
|
+
if device_config is not None:
|
|
239
|
+
payload["device_config"] = device_config
|
|
240
|
+
|
|
241
|
+
resp = self._session.post(f"{self.base_url}/api/op/qpus/connect", json=payload)
|
|
242
|
+
resp.raise_for_status()
|
|
243
|
+
return resp.json()
|
|
244
|
+
|
|
245
|
+
def toggle_qpu(self, qpu_id: str, enabled: bool) -> dict[str, Any]:
|
|
246
|
+
"""Toggle QPU driver state (admin-only).
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
qpu_id: ID of the QPU.
|
|
250
|
+
enabled: Whether the QPU should be enabled.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Response dict.
|
|
254
|
+
"""
|
|
255
|
+
resp = self._session.post(
|
|
256
|
+
f"{self.base_url}/api/op/qpu/toggle",
|
|
257
|
+
json={"id": qpu_id, "enabled": enabled},
|
|
258
|
+
)
|
|
259
|
+
resp.raise_for_status()
|
|
260
|
+
return resp.json()
|
|
261
|
+
|
|
262
|
+
# -- Notifications -------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def list_notifications(self) -> list[dict[str, Any]]:
|
|
265
|
+
"""List notifications visible to the authenticated user.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
A list of notification record dicts.
|
|
269
|
+
"""
|
|
270
|
+
resp = self._session.get(
|
|
271
|
+
f"{self.base_url}/api/collections/notifications/records"
|
|
272
|
+
)
|
|
273
|
+
resp.raise_for_status()
|
|
274
|
+
data = resp.json()
|
|
275
|
+
if isinstance(data, list):
|
|
276
|
+
return data
|
|
277
|
+
return data.get("items", [])
|
|
278
|
+
|
|
279
|
+
def dismiss_notification(self, notification_id: str) -> dict[str, Any]:
|
|
280
|
+
"""Dismiss a notification for the authenticated user.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
notification_id: ID of the notification.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Response dict.
|
|
287
|
+
"""
|
|
288
|
+
resp = self._session.post(
|
|
289
|
+
f"{self.base_url}/api/notifications/{notification_id}/dismiss"
|
|
290
|
+
)
|
|
291
|
+
resp.raise_for_status()
|
|
292
|
+
return resp.json()
|
|
293
|
+
|
|
294
|
+
# -- Booking Slots (time_slots) ------------------------------------------
|
|
295
|
+
|
|
296
|
+
def list_time_slots(self) -> list[dict[str, Any]]:
|
|
297
|
+
"""List all booking slots.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
A list of booking slot record dicts.
|
|
301
|
+
"""
|
|
302
|
+
resp = self._session.get(f"{self.base_url}/api/collections/time_slots/records")
|
|
303
|
+
resp.raise_for_status()
|
|
304
|
+
data = resp.json()
|
|
305
|
+
return data.get("items", [])
|
|
306
|
+
|
|
307
|
+
def create_time_slot(
|
|
308
|
+
self,
|
|
309
|
+
start_time: str,
|
|
310
|
+
end_time: str,
|
|
311
|
+
booked_by: str | None = None,
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""Create a new booking slot.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
start_time: Start time RFC3339 string.
|
|
317
|
+
end_time: End time RFC3339 string.
|
|
318
|
+
booked_by: Optional ID of the user booking the slot.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The created booking slot dict.
|
|
322
|
+
"""
|
|
323
|
+
payload = {"start_time": start_time, "end_time": end_time}
|
|
324
|
+
if booked_by is not None:
|
|
325
|
+
payload["booked_by"] = booked_by
|
|
326
|
+
resp = self._session.post(
|
|
327
|
+
f"{self.base_url}/api/collections/time_slots/records", json=payload
|
|
328
|
+
)
|
|
329
|
+
resp.raise_for_status()
|
|
330
|
+
return resp.json()
|
|
331
|
+
|
|
332
|
+
def update_time_slot(
|
|
333
|
+
self,
|
|
334
|
+
slot_id: str,
|
|
335
|
+
start_time: str | None = None,
|
|
336
|
+
end_time: str | None = None,
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
"""Update an existing booking slot.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
slot_id: ID of the booking slot.
|
|
342
|
+
start_time: Optional start time RFC3339 string.
|
|
343
|
+
end_time: Optional end time RFC3339 string.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
The updated booking slot dict.
|
|
347
|
+
"""
|
|
348
|
+
payload = {}
|
|
349
|
+
if start_time is not None:
|
|
350
|
+
payload["start_time"] = start_time
|
|
351
|
+
if end_time is not None:
|
|
352
|
+
payload["end_time"] = end_time
|
|
353
|
+
resp = self._session.patch(
|
|
354
|
+
f"{self.base_url}/api/collections/time_slots/records/{slot_id}",
|
|
355
|
+
json=payload,
|
|
356
|
+
)
|
|
357
|
+
resp.raise_for_status()
|
|
358
|
+
return resp.json()
|
|
359
|
+
|
|
360
|
+
def delete_time_slot(self, slot_id: str) -> None:
|
|
361
|
+
"""Delete a booking slot.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
slot_id: ID of the booking slot.
|
|
365
|
+
"""
|
|
366
|
+
resp = self._session.delete(
|
|
367
|
+
f"{self.base_url}/api/collections/time_slots/records/{slot_id}"
|
|
368
|
+
)
|
|
369
|
+
resp.raise_for_status()
|
|
370
|
+
|
|
371
|
+
# -- QPU Time Requests ---------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def list_time_requests(self) -> list[dict[str, Any]]:
|
|
374
|
+
"""List QPU time requests.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
A list of QPU time request record dicts.
|
|
378
|
+
"""
|
|
379
|
+
resp = self._session.get(
|
|
380
|
+
f"{self.base_url}/api/collections/qpu_time_requests/records"
|
|
381
|
+
)
|
|
382
|
+
resp.raise_for_status()
|
|
383
|
+
data = resp.json()
|
|
384
|
+
return data.get("items", [])
|
|
385
|
+
|
|
386
|
+
def create_time_request(
|
|
387
|
+
self, seconds: int, requested_reason: str | None = None
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
"""Create a new QPU time request.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
seconds: Requested duration in seconds.
|
|
393
|
+
requested_reason: Optional explanation.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The created QPU time request record dict.
|
|
397
|
+
"""
|
|
398
|
+
payload = {"seconds": seconds}
|
|
399
|
+
if requested_reason is not None:
|
|
400
|
+
payload["requested_reason"] = requested_reason
|
|
401
|
+
resp = self._session.post(
|
|
402
|
+
f"{self.base_url}/api/collections/qpu_time_requests/records", json=payload
|
|
403
|
+
)
|
|
404
|
+
resp.raise_for_status()
|
|
405
|
+
return resp.json()
|
|
406
|
+
|
|
407
|
+
def update_time_request(
|
|
408
|
+
self,
|
|
409
|
+
request_id: str,
|
|
410
|
+
status: str,
|
|
411
|
+
rejection_reason: str | None = None,
|
|
412
|
+
) -> dict[str, Any]:
|
|
413
|
+
"""Update/Handle a QPU time request (admin-only).
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
request_id: ID of the time request.
|
|
417
|
+
status: "approved" or "rejected".
|
|
418
|
+
rejection_reason: Optional explanation if rejected.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
The updated request record dict.
|
|
422
|
+
"""
|
|
423
|
+
payload = {"status": status}
|
|
424
|
+
if rejection_reason is not None:
|
|
425
|
+
payload["rejection_reason"] = rejection_reason
|
|
426
|
+
resp = self._session.patch(
|
|
427
|
+
f"{self.base_url}/api/collections/qpu_time_requests/records/{request_id}",
|
|
428
|
+
json=payload,
|
|
429
|
+
)
|
|
430
|
+
resp.raise_for_status()
|
|
431
|
+
return resp.json()
|
|
432
|
+
|
|
433
|
+
# -- Admin User Management -----------------------------------------------
|
|
434
|
+
|
|
435
|
+
def list_users(self) -> list[dict[str, Any]]:
|
|
436
|
+
"""List all registered users (admin-only).
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
A list of user record dicts.
|
|
440
|
+
"""
|
|
441
|
+
resp = self._session.get(f"{self.base_url}/api/collections/users/records")
|
|
442
|
+
resp.raise_for_status()
|
|
443
|
+
data = resp.json()
|
|
444
|
+
return data.get("items", [])
|
|
445
|
+
|
|
446
|
+
def allocate_qpu_time(self, user_id: str, seconds: int) -> dict[str, Any]:
|
|
447
|
+
"""Allocate QPU time to a user (admin-only).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
user_id: ID of the user.
|
|
451
|
+
seconds: Total allocated seconds.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The updated user record dict.
|
|
455
|
+
"""
|
|
456
|
+
resp = self._session.patch(
|
|
457
|
+
f"{self.base_url}/api/collections/users/records/{user_id}",
|
|
458
|
+
json={"qpu_seconds": seconds},
|
|
459
|
+
)
|
|
460
|
+
resp.raise_for_status()
|
|
461
|
+
return resp.json()
|
|
462
|
+
|
|
463
|
+
# -- Auth helpers --------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def auth_with_password(self, identity: str, password: str) -> dict[str, Any]:
|
|
466
|
+
"""Authenticate as a regular user using email/password.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
identity: Email or username.
|
|
470
|
+
password: User password.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
The auth response payload including token and record.
|
|
474
|
+
"""
|
|
475
|
+
resp = self._session.post(
|
|
476
|
+
f"{self.base_url}/api/collections/users/auth-with-password",
|
|
477
|
+
json={"identity": identity, "password": password},
|
|
478
|
+
)
|
|
479
|
+
resp.raise_for_status()
|
|
480
|
+
data = resp.json()
|
|
481
|
+
token = data.get("token")
|
|
482
|
+
if token:
|
|
483
|
+
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
484
|
+
return data
|
|
485
|
+
|
|
486
|
+
# -- lifecycle -----------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def close(self) -> None:
|
|
489
|
+
"""Close the underlying HTTP session."""
|
|
490
|
+
self._session.close()
|
|
491
|
+
|
|
492
|
+
def __enter__(self) -> "QPIClient":
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def __exit__(self, *exc: object) -> None:
|
|
496
|
+
self.close()
|
qpi_client/provider.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Qiskit provider integration for the QPI platform.
|
|
2
|
+
|
|
3
|
+
This module exposes :class:`QPIBackend` (a Qiskit ``BackendV2``) and
|
|
4
|
+
:class:`QPIJob` (a Qiskit ``JobV1``) so that QPI can be used as a
|
|
5
|
+
drop-in Qiskit execution target::
|
|
6
|
+
|
|
7
|
+
from qiskit.circuit import QuantumCircuit
|
|
8
|
+
from qpi_client import QPIClient, QPIBackend
|
|
9
|
+
|
|
10
|
+
client = QPIClient("http://localhost:8090", api_token="tok")
|
|
11
|
+
backend = QPIBackend(client, num_qubits=5)
|
|
12
|
+
|
|
13
|
+
qc = QuantumCircuit(2, 2)
|
|
14
|
+
qc.h(0)
|
|
15
|
+
qc.cx(0, 1)
|
|
16
|
+
qc.measure([0, 1], [0, 1])
|
|
17
|
+
|
|
18
|
+
job = backend.run(qc, shots=4096)
|
|
19
|
+
result = job.result(timeout=120)
|
|
20
|
+
print(result.get_counts())
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any, Sequence
|
|
27
|
+
|
|
28
|
+
from qiskit.circuit import QuantumCircuit
|
|
29
|
+
from qiskit.providers import BackendV2, JobStatus, JobV1, Options
|
|
30
|
+
from qiskit.qasm3 import dumps as qasm3_dumps
|
|
31
|
+
from qiskit.result import Result
|
|
32
|
+
from qiskit.result.models import ExperimentResult, ExperimentResultData
|
|
33
|
+
from qiskit.transpiler import Target
|
|
34
|
+
|
|
35
|
+
from qpi_client.client import QPIClient
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# QPIJob
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class QPIJob(JobV1):
|
|
43
|
+
"""A Qiskit-compatible job handle backed by the QPI REST API.
|
|
44
|
+
|
|
45
|
+
Instances are created by :meth:`QPIBackend.run` or :meth:`QPIClient.job`;
|
|
46
|
+
you should not need to instantiate this class directly.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
backend: "QPIBackend" | None,
|
|
52
|
+
job_id: str,
|
|
53
|
+
client: QPIClient,
|
|
54
|
+
**kwargs: Any,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(backend, job_id, **kwargs)
|
|
57
|
+
self._client = client
|
|
58
|
+
self._result: Result | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def id(self) -> str:
|
|
62
|
+
"""Server-assigned job ID."""
|
|
63
|
+
return self.job_id()
|
|
64
|
+
|
|
65
|
+
# -- JobV1 interface -----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def submit(self) -> None:
|
|
68
|
+
"""No-op — the job was already submitted by the backend."""
|
|
69
|
+
|
|
70
|
+
def result(
|
|
71
|
+
self,
|
|
72
|
+
timeout: float | None = None,
|
|
73
|
+
wait: float = 5.0,
|
|
74
|
+
) -> Result:
|
|
75
|
+
"""Block until the job completes and return a :class:`qiskit.result.Result`.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
timeout: Maximum seconds to wait. *None* means wait indefinitely.
|
|
79
|
+
wait: Polling interval in seconds.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A Qiskit :class:`Result` object populated with counts and
|
|
83
|
+
(optionally) memory from the QPI server response.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
TimeoutError: If the job does not finish within *timeout* seconds.
|
|
87
|
+
RuntimeError: If the job fails or is cancelled on the server.
|
|
88
|
+
"""
|
|
89
|
+
if self._result is not None:
|
|
90
|
+
return self._result
|
|
91
|
+
|
|
92
|
+
start = time.monotonic()
|
|
93
|
+
while True:
|
|
94
|
+
data = self._client.get_job(self.job_id())
|
|
95
|
+
status = data.get("status", "")
|
|
96
|
+
if status in ("completed", "failed", "cancelled"):
|
|
97
|
+
break
|
|
98
|
+
if timeout is not None and (time.monotonic() - start) > timeout:
|
|
99
|
+
raise TimeoutError(
|
|
100
|
+
f"Job {self.job_id()} did not complete within {timeout}s"
|
|
101
|
+
)
|
|
102
|
+
time.sleep(wait)
|
|
103
|
+
|
|
104
|
+
if status == "failed":
|
|
105
|
+
error_msg = ""
|
|
106
|
+
results_data = data.get("results")
|
|
107
|
+
if isinstance(results_data, dict):
|
|
108
|
+
error_msg = results_data.get("error", "")
|
|
109
|
+
raise RuntimeError(f"Job {self.job_id()} failed: {error_msg}")
|
|
110
|
+
|
|
111
|
+
if status == "cancelled":
|
|
112
|
+
raise RuntimeError(f"Job {self.job_id()} was cancelled")
|
|
113
|
+
|
|
114
|
+
self._result = self._build_result(data)
|
|
115
|
+
return self._result
|
|
116
|
+
|
|
117
|
+
def status(self) -> JobStatus:
|
|
118
|
+
"""Return the current server-side status of the job."""
|
|
119
|
+
data = self._client.get_job(self.job_id())
|
|
120
|
+
_STATUS_MAP: dict[str, JobStatus] = {
|
|
121
|
+
"pending": JobStatus.QUEUED,
|
|
122
|
+
"queued": JobStatus.QUEUED,
|
|
123
|
+
"running": JobStatus.RUNNING,
|
|
124
|
+
"completed": JobStatus.DONE,
|
|
125
|
+
"failed": JobStatus.ERROR,
|
|
126
|
+
"cancelled": JobStatus.CANCELLED,
|
|
127
|
+
}
|
|
128
|
+
return _STATUS_MAP.get(data.get("status", ""), JobStatus.ERROR)
|
|
129
|
+
|
|
130
|
+
def cancel(self) -> None:
|
|
131
|
+
"""Request cancellation of this job on the server."""
|
|
132
|
+
self._client.cancel_job(self.job_id())
|
|
133
|
+
|
|
134
|
+
# -- internal helpers ----------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _build_result(self, data: dict[str, Any]) -> Result:
|
|
137
|
+
"""Construct a :class:`qiskit.result.Result` from the API response.
|
|
138
|
+
|
|
139
|
+
The server ``results`` payload may be:
|
|
140
|
+
* A dict with a top-level ``"circuit_results"`` list (one entry per
|
|
141
|
+
submitted circuit).
|
|
142
|
+
* A single dict with ``"counts"``/``"hex_counts"``/``"memory"`` keys
|
|
143
|
+
when only one circuit was submitted.
|
|
144
|
+
* ``None`` (edge-case) — we still return a valid *Result* with no
|
|
145
|
+
experiment data.
|
|
146
|
+
"""
|
|
147
|
+
results_payload: Any = data.get("results") or {}
|
|
148
|
+
|
|
149
|
+
# Normalise to a list of per-circuit result dicts.
|
|
150
|
+
if isinstance(results_payload, dict):
|
|
151
|
+
circuit_results: list[dict[str, Any]] = results_payload.get(
|
|
152
|
+
"circuit_results", []
|
|
153
|
+
)
|
|
154
|
+
if not circuit_results:
|
|
155
|
+
# Treat the whole dict as a single-circuit result.
|
|
156
|
+
circuit_results = [results_payload]
|
|
157
|
+
elif isinstance(results_payload, list):
|
|
158
|
+
circuit_results = results_payload
|
|
159
|
+
else:
|
|
160
|
+
circuit_results = []
|
|
161
|
+
|
|
162
|
+
experiment_results: list[ExperimentResult] = []
|
|
163
|
+
for idx, cr in enumerate(circuit_results):
|
|
164
|
+
counts = cr.get("counts") or cr.get("hex_counts") or {}
|
|
165
|
+
# Ensure keys are hex-string formatted ("0x…").
|
|
166
|
+
hex_counts: dict[str, int] = {}
|
|
167
|
+
for key, val in counts.items():
|
|
168
|
+
if isinstance(key, int):
|
|
169
|
+
hex_counts[hex(key)] = int(val)
|
|
170
|
+
elif key.startswith("0x") or key.startswith("0X"):
|
|
171
|
+
hex_counts[key] = int(val)
|
|
172
|
+
else:
|
|
173
|
+
# Assume binary string — convert to hex.
|
|
174
|
+
try:
|
|
175
|
+
hex_counts[hex(int(key, 2))] = int(val)
|
|
176
|
+
except ValueError:
|
|
177
|
+
hex_counts[key] = int(val)
|
|
178
|
+
|
|
179
|
+
exp_data = ExperimentResultData(
|
|
180
|
+
counts=hex_counts,
|
|
181
|
+
memory=cr.get("memory"),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
experiment_results.append(
|
|
185
|
+
ExperimentResult(
|
|
186
|
+
shots=cr.get(
|
|
187
|
+
"shots", sum(hex_counts.values()) if hex_counts else 0
|
|
188
|
+
),
|
|
189
|
+
success=True,
|
|
190
|
+
data=exp_data,
|
|
191
|
+
header=cr.get("header"),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return Result(
|
|
196
|
+
backend_name=self.backend().name if self.backend() else "qpi",
|
|
197
|
+
backend_version="0.1.0",
|
|
198
|
+
qobj_id=None,
|
|
199
|
+
job_id=self.job_id(),
|
|
200
|
+
success=True,
|
|
201
|
+
results=experiment_results,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# QPIBackend
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class QPIBackend(BackendV2):
|
|
211
|
+
"""A Qiskit ``BackendV2`` that submits circuits to the QPI orchestrator.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
client: An authenticated :class:`QPIClient` instance.
|
|
215
|
+
name: Human-readable backend name (default ``"qpi"``).
|
|
216
|
+
num_qubits: Number of qubits the backend advertises to the Qiskit
|
|
217
|
+
transpiler. Defaults to a large value so circuits of any
|
|
218
|
+
reasonable size compile without resizing the target.
|
|
219
|
+
**kwargs: Forwarded to :class:`BackendV2.__init__`.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
client: QPIClient,
|
|
225
|
+
name: str = "qpi",
|
|
226
|
+
**kwargs: Any,
|
|
227
|
+
) -> None:
|
|
228
|
+
super().__init__(name=name, **kwargs)
|
|
229
|
+
self._client = client
|
|
230
|
+
self._num_qubits = self._resolve_num_qubits(name)
|
|
231
|
+
self._target = Target(num_qubits=self._num_qubits)
|
|
232
|
+
self._options = Options()
|
|
233
|
+
self._options.update_options(
|
|
234
|
+
shots=1024,
|
|
235
|
+
meas_level=2,
|
|
236
|
+
meas_return="single",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _resolve_num_qubits(self, name: str) -> int:
|
|
240
|
+
"""Query the orchestrator for QPU info and return its num_qubits.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
RuntimeError: If the QPU cannot be found or has no valid num_qubits.
|
|
244
|
+
"""
|
|
245
|
+
qpu = self._client.get_qpu(name)
|
|
246
|
+
try:
|
|
247
|
+
return int(qpu["num_qubits"])
|
|
248
|
+
except (TypeError, KeyError) as exp:
|
|
249
|
+
raise RuntimeError(
|
|
250
|
+
f"QPU '{name}' has no valid num_qubits (got {qpu.get('num_qubits')!r})"
|
|
251
|
+
) from exp
|
|
252
|
+
|
|
253
|
+
# -- BackendV2 required properties ---------------------------------------
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def target(self) -> Target:
|
|
257
|
+
"""Return the transpiler :class:`Target` for this backend."""
|
|
258
|
+
return self._target
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def max_circuits(self) -> int | None:
|
|
262
|
+
"""No server-side limit on the number of circuits per job."""
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def _default_options(cls) -> Options:
|
|
267
|
+
return Options(shots=1024, meas_level=2, meas_return="single")
|
|
268
|
+
|
|
269
|
+
# -- execution -----------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def run(
|
|
272
|
+
self,
|
|
273
|
+
circuit: QuantumCircuit | Sequence[QuantumCircuit] | None = None,
|
|
274
|
+
qasm: str | None = None,
|
|
275
|
+
shots: int = 1024,
|
|
276
|
+
meas_level: int = 2,
|
|
277
|
+
meas_return: str = "single",
|
|
278
|
+
parameter_values: list[list[float]] | list[dict[Any, float]] | None = None,
|
|
279
|
+
**kwargs: Any,
|
|
280
|
+
) -> QPIJob:
|
|
281
|
+
"""Submit a quantum job to QPI.
|
|
282
|
+
|
|
283
|
+
Exactly one of ``circuit`` or ``qasm`` must be provided.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
circuit: A single :class:`QuantumCircuit` or a list thereof.
|
|
287
|
+
qasm: A raw OpenQASM string (alternative to ``circuit``).
|
|
288
|
+
shots: Number of shots.
|
|
289
|
+
meas_level: Measurement level (``2`` = classified bits).
|
|
290
|
+
meas_return: ``"single"`` or ``"avg"``.
|
|
291
|
+
parameter_values: Parameter bindings. For circuits this may be a
|
|
292
|
+
list of dicts mapping :class:`Parameter` objects to floats.
|
|
293
|
+
For raw QASM this should be a list of lists
|
|
294
|
+
(``[[0.5, 1.0]]``).
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
A :class:`QPIJob` handle that can be polled or awaited.
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If neither or both of ``circuit`` and ``qasm`` are
|
|
301
|
+
supplied.
|
|
302
|
+
"""
|
|
303
|
+
if circuit is None and qasm is None:
|
|
304
|
+
raise ValueError("Either 'circuit' or 'qasm' must be provided")
|
|
305
|
+
if circuit is not None and qasm is not None:
|
|
306
|
+
raise ValueError("Only one of 'circuit' or 'qasm' should be provided")
|
|
307
|
+
|
|
308
|
+
pv = parameter_values
|
|
309
|
+
circuit_payloads: list[dict[str, Any]] = []
|
|
310
|
+
|
|
311
|
+
if circuit is not None:
|
|
312
|
+
if isinstance(circuit, QuantumCircuit):
|
|
313
|
+
circuits = [circuit]
|
|
314
|
+
else:
|
|
315
|
+
circuits = list(circuit)
|
|
316
|
+
|
|
317
|
+
# Grow the transpiler target if the circuit is larger than expected
|
|
318
|
+
max_qubits = max(qc.num_qubits for qc in circuits)
|
|
319
|
+
if max_qubits > self._num_qubits:
|
|
320
|
+
self._num_qubits = max_qubits
|
|
321
|
+
self._target = Target(num_qubits=max_qubits)
|
|
322
|
+
|
|
323
|
+
for idx, qc in enumerate(circuits):
|
|
324
|
+
if pv and idx < len(pv):
|
|
325
|
+
pval = pv[idx]
|
|
326
|
+
if isinstance(pval, dict) and pval:
|
|
327
|
+
bound_qc = qc.assign_parameters(pval)
|
|
328
|
+
qasm_str = qasm3_dumps(bound_qc)
|
|
329
|
+
ordered_values = [float(pval[p]) for p in qc.parameters]
|
|
330
|
+
circuit_payloads.append(
|
|
331
|
+
{
|
|
332
|
+
"circuit": qasm_str,
|
|
333
|
+
"parameter_values": [ordered_values],
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
qasm_str = qasm3_dumps(qc)
|
|
339
|
+
circuit_payloads.append({"circuit": qasm_str})
|
|
340
|
+
else:
|
|
341
|
+
payload: dict[str, Any] = {"circuit": qasm}
|
|
342
|
+
if pv:
|
|
343
|
+
# Normalise single list to list of lists
|
|
344
|
+
if isinstance(pv[0], (int, float)):
|
|
345
|
+
pv = [pv] # type: ignore[assignment]
|
|
346
|
+
payload["parameter_values"] = pv
|
|
347
|
+
circuit_payloads.append(payload)
|
|
348
|
+
|
|
349
|
+
job_id = self._client.submit_job(
|
|
350
|
+
circuits=circuit_payloads,
|
|
351
|
+
shots=shots,
|
|
352
|
+
meas_level=meas_level,
|
|
353
|
+
meas_return=meas_return,
|
|
354
|
+
)
|
|
355
|
+
return QPIJob(self, job_id, self._client)
|
|
356
|
+
|
|
357
|
+
def job(self, job_id: str) -> QPIJob:
|
|
358
|
+
"""Retrieve an existing job by ID.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
job_id: The server-assigned job ID.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
A :class:`QPIJob` bound to this backend.
|
|
365
|
+
"""
|
|
366
|
+
return QPIJob(self, job_id, self._client)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qpi-client
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Python client SDK for the QPI quantum computing platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/sopherapps/qpi
|
|
7
|
+
Project-URL: Repository, https://github.com/sopherapps/qpi
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: requests>=2.28.0
|
|
11
|
+
Requires-Dist: qiskit>=1.0.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
qpi_client/__init__.py,sha256=Lh6wOuB3Z-5qu5KbFZWHFKjdr20cJlHAlk0W2sbkqD4,1096
|
|
2
|
+
qpi_client/client.py,sha256=Fb9u1e8kwLb-CCKq4vjKoIgMVOx0hLdOuzdwrPfTZTU,16004
|
|
3
|
+
qpi_client/provider.py,sha256=NE3PmZNulX9H_reOvJM329mPZvcjbxXYdG_GrtiVCJo,13119
|
|
4
|
+
qpi_client-0.0.3.dist-info/METADATA,sha256=_sk1BxN44Q5uWG7qA9yfand3IutX11kVIEbtRkLTZxc,374
|
|
5
|
+
qpi_client-0.0.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
qpi_client-0.0.3.dist-info/top_level.txt,sha256=2pfYOF8Y6ubKXMZuxOuwa4IdL9SjQ970y3qW1zM5MwI,11
|
|
7
|
+
qpi_client-0.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qpi_client
|