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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ qpi_client