qpi-client 0.0.3__tar.gz
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-0.0.3/PKG-INFO +11 -0
- qpi_client-0.0.3/pyproject.toml +32 -0
- qpi_client-0.0.3/qpi_client/__init__.py +38 -0
- qpi_client-0.0.3/qpi_client/client.py +496 -0
- qpi_client-0.0.3/qpi_client/provider.py +366 -0
- qpi_client-0.0.3/qpi_client.egg-info/PKG-INFO +11 -0
- qpi_client-0.0.3/qpi_client.egg-info/SOURCES.txt +11 -0
- qpi_client-0.0.3/qpi_client.egg-info/dependency_links.txt +1 -0
- qpi_client-0.0.3/qpi_client.egg-info/requires.txt +2 -0
- qpi_client-0.0.3/qpi_client.egg-info/top_level.txt +1 -0
- qpi_client-0.0.3/setup.cfg +4 -0
- qpi_client-0.0.3/tests/test_client.py +410 -0
- qpi_client-0.0.3/tests/test_provider.py +270 -0
|
@@ -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,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qpi-client"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "Python client SDK for the QPI quantum computing platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"requests>=2.28.0",
|
|
14
|
+
"qiskit>=1.0.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0.0",
|
|
20
|
+
"ruff>=0.3.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/sopherapps/qpi"
|
|
25
|
+
Repository = "https://github.com/sopherapps/qpi"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["qpi_client*"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
pythonpath = ["."]
|
|
@@ -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"]
|
|
@@ -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()
|