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.
@@ -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()