diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..eea8945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `examples/` directory with sync and async downstream-SDK integration scripts demonstrating the extension API against the free `simulator`. - `QctrlQaoaJobCreationPayload` and `QctrlQaoaJobInput` for submitting Q-CTRL QAOA maxcut combinatorial-optimization jobs via `create_job`. The `create_job` body union now also accepts `QctrlQaoaJobCreationPayload`. -- `cost_model` optional field on `BaseJob`, `GetCircuitJobResponse`, and `GetJobResponse`, typed as `CostModel` (`"quantum_compute_time"` or `"execution_time"`). +- `cost_model` optional field on `BaseJob`, `GetCircuitJobResponse`, and `GetJobResponse`, typed as `CostModel` (`"quantum_compute_time"`, `"execution_time"`, `"QCT"`, or `"2QGE_operations"`). ### Changed +- Extended the local OpenAPI overlay so `CostModel` accepts live API values (`QCT`, `2QGE_operations`) returned by completed jobs; fixes `get_job` deserialization failures during polling. - `NativeCircuitInput.qubits` and `JsonMultiCircuitInput.qubits` are now `int | Unset` (previously `float | Unset`), matching upstream's tightening to `format: int32, minimum: 1`. `QisCircuitInput.qubits` already had this type locally via the OpenAPI overlay; that overlay action has been removed now that upstream is correct natively. ## [0.1.1] - 2026-04-30 diff --git a/README.md b/README.md index 992bbde..69b8437 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,15 @@ Each generated endpoint module exposes four callables: `sync`, `sync_detailed`, For options (`api_key`, `base_url`, `max_retries`, `timeout`, `extension`), error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). +## Examples + +Runnable scripts for downstream SDK integration live in [`examples/`](examples/): + +- [`downstream_integration.py`](examples/downstream_integration.py) — sync `ClientExtension` integration (event hooks, error mapping, Bell-state job). +- [`downstream_integration_async.py`](examples/downstream_integration_async.py) — the same flow on the async path. + +See [`examples/README.md`](examples/README.md) for setup (`pip install ionq-core`, `export IONQ_API_KEY=...`). + ## Versioning This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..21047f2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,49 @@ +# ionq-core examples + +Runnable scripts that demonstrate how downstream SDKs integrate with `ionq-core` +through the [extension API](https://ionq.github.io/ionq-core-python/ionq_core/extensions.html). + +## Setup + +```sh +pip install ionq-core +export IONQ_API_KEY=your-api-key +``` + +Create a free account at [identity.ionq.com/create-account](https://identity.ionq.com/create-account) if you need an API key. Both scripts target the free `simulator` backend. + +On Windows PowerShell: + +```powershell +$env:IONQ_API_KEY = "your-api-key" +python examples/downstream_integration.py +``` + +From a clone of this repository you can also use: + +```sh +uv run python examples/downstream_integration.py +``` + +## Scripts + +| Script | What it demonstrates | +| --- | --- | +| [`downstream_integration.py`](downstream_integration.py) | Sync `ClientExtension`: `user_agent_token`, `default_headers`, `EventHook`, `error_mapper`, Bell-state job lifecycle | +| [`downstream_integration_async.py`](downstream_integration_async.py) | Same flow with `AsyncEventHook`, `async_wait_for_job`, and `asyncio` endpoints | + +Each script configures a `ClientExtension` with: + +- `user_agent_token` — downstream SDK identity in the `User-Agent` header +- `default_headers` — SDK-specific headers on every request +- `EventHook` / `AsyncEventHook` — request, response, and error logging +- `error_mapper` — wraps `APIError` and `RateLimitError` (including `request_id`) into SDK types when a real API call fails + +After submitting a Bell-state circuit and waiting for completion, the scripts print +the job id, status, and measured probabilities (expect roughly equal weight on +`|00⟩` and `|11⟩`). + +```sh +python examples/downstream_integration.py +python examples/downstream_integration_async.py +``` diff --git a/examples/__pycache__/downstream_integration.cpython-311.pyc b/examples/__pycache__/downstream_integration.cpython-311.pyc new file mode 100644 index 0000000..74a597d Binary files /dev/null and b/examples/__pycache__/downstream_integration.cpython-311.pyc differ diff --git a/examples/__pycache__/downstream_integration_async.cpython-311.pyc b/examples/__pycache__/downstream_integration_async.cpython-311.pyc new file mode 100644 index 0000000..b29d075 Binary files /dev/null and b/examples/__pycache__/downstream_integration_async.cpython-311.pyc differ diff --git a/examples/__pycache__/sdk_shared.cpython-311.pyc b/examples/__pycache__/sdk_shared.cpython-311.pyc new file mode 100644 index 0000000..f3e19b8 Binary files /dev/null and b/examples/__pycache__/sdk_shared.cpython-311.pyc differ diff --git a/examples/downstream_integration.py b/examples/downstream_integration.py new file mode 100644 index 0000000..5c14b64 --- /dev/null +++ b/examples/downstream_integration.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Sync downstream-SDK example: ClientExtension + Bell state on simulator. + +Shows how a higher-level SDK wraps ionq-core with ``user_agent_token``, +``default_headers``, an ``EventHook``, and an ``error_mapper`` that translates +``APIError`` / ``RateLimitError`` into SDK-defined exception types. + +Extension API reference: +https://ionq.github.io/ionq-core-python/ionq_core/extensions.html +""" + +from __future__ import annotations + +import logging +import sys + +import httpx + +from ionq_core import APIError, ClientExtension, EventHook, IonQClient, RateLimitError, wait_for_job +from ionq_core.api.default import create_job, get_job_probabilities +from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload + +SDK_NAME = "example-sdk/0.1.0" +logger = logging.getLogger(__name__) + +BELL_CIRCUIT = CircuitJobCreationPayload.from_dict( + { + "type": "ionq.circuit.v1", + "backend": "simulator", + "shots": 100, + "input": { + "gateset": "qis", + "qubits": 2, + "circuit": [ + {"gate": "h", "target": 0}, + {"gate": "cnot", "control": 0, "target": 1}, + ], + }, + } +) + + +class ExampleSDKError(Exception): + """Base exception for this example downstream SDK.""" + + +class ExampleAPIError(ExampleSDKError): + """SDK wrapper around ionq-core HTTP API errors.""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + request_id: str | None = None, + ) -> None: + self.status_code = status_code + self.request_id = request_id + super().__init__(message) + + +class ExampleRateLimitError(ExampleAPIError): + """SDK wrapper around ionq-core rate-limit errors.""" + + def __init__(self, message: str, *, retry_after: float | None = None) -> None: + self.retry_after = retry_after + super().__init__(message, status_code=429) + + +class LoggingHook(EventHook): + """Sync event hook: log requests, responses, and transport failures.""" + + def on_request(self, request: httpx.Request) -> None: + logger.info("--> %s %s", request.method, request.url) + + def on_response(self, request: httpx.Request, response: httpx.Response) -> None: + logger.info("<-- %s %s", response.status_code, request.url) + + def on_error(self, request: httpx.Request, error: Exception) -> None: + logger.warning("!! %s %s failed: %s", request.method, request.url, error) + + +def map_ionq_error(exc: Exception) -> Exception: + if isinstance(exc, RateLimitError): + return ExampleRateLimitError(f"IonQ rate limit: {exc.message}", retry_after=exc.retry_after) + if isinstance(exc, APIError): + return ExampleAPIError( + f"IonQ API {exc.status_code}: {exc.message}", + status_code=exc.status_code, + request_id=exc.request_id, + ) + return exc + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + logging.getLogger("httpx").setLevel(logging.WARNING) + + extension = ClientExtension( + user_agent_token=SDK_NAME, + default_headers={"X-Example-SDK": SDK_NAME}, + event_hooks=(LoggingHook(),), + error_mapper=map_ionq_error, + ) + + try: + with IonQClient(extension=extension) as client: + job = create_job.sync(client=client, body=BELL_CIRCUIT) + if job is None: + raise ExampleSDKError("create_job returned no job") + + completed = wait_for_job(client, job.id, timeout=120) + probs = get_job_probabilities.sync(uuid=job.id, client=client) + if probs is None: + raise ExampleSDKError(f"get_job_probabilities returned no data for job {job.id}") + + print() + print("Bell-state job on simulator") + print(f" job_id: {completed.id}") + print(f" status: {completed.status}") + print(f" results: {probs.additional_properties}") + except ExampleSDKError: + logger.exception("downstream SDK call failed") + return 1 + except ValueError as exc: + logger.error("%s", exc) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/downstream_integration_async.py b/examples/downstream_integration_async.py new file mode 100644 index 0000000..e4ed2c0 --- /dev/null +++ b/examples/downstream_integration_async.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Async downstream-SDK example: ClientExtension + Bell state on simulator. + +Same flow as ``downstream_integration.py`` using ``AsyncEventHook``, +``async_wait_for_job``, and the ``asyncio`` endpoint variants. + +Extension API reference: +https://ionq.github.io/ionq-core-python/ionq_core/extensions.html +""" + +from __future__ import annotations + +import asyncio +import logging +import sys + +import httpx + +from ionq_core import ( + APIError, + AsyncEventHook, + ClientExtension, + IonQClient, + RateLimitError, + async_wait_for_job, +) +from ionq_core.api.default import create_job, get_job_probabilities +from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload + +SDK_NAME = "example-sdk/0.1.0" +logger = logging.getLogger(__name__) + +BELL_CIRCUIT = CircuitJobCreationPayload.from_dict( + { + "type": "ionq.circuit.v1", + "backend": "simulator", + "shots": 100, + "input": { + "gateset": "qis", + "qubits": 2, + "circuit": [ + {"gate": "h", "target": 0}, + {"gate": "cnot", "control": 0, "target": 1}, + ], + }, + } +) + + +class ExampleSDKError(Exception): + """Base exception for this example downstream SDK.""" + + +class ExampleAPIError(ExampleSDKError): + """SDK wrapper around ionq-core HTTP API errors.""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + request_id: str | None = None, + ) -> None: + self.status_code = status_code + self.request_id = request_id + super().__init__(message) + + +class ExampleRateLimitError(ExampleAPIError): + """SDK wrapper around ionq-core rate-limit errors.""" + + def __init__(self, message: str, *, retry_after: float | None = None) -> None: + self.retry_after = retry_after + super().__init__(message, status_code=429) + + +class AsyncLoggingHook(AsyncEventHook): + """Async event hook: log requests, responses, and transport failures.""" + + async def on_request(self, request: httpx.Request) -> None: + logger.info("--> %s %s", request.method, request.url) + + async def on_response(self, request: httpx.Request, response: httpx.Response) -> None: + logger.info("<-- %s %s", response.status_code, request.url) + + async def on_error(self, request: httpx.Request, error: Exception) -> None: + logger.warning("!! %s %s failed: %s", request.method, request.url, error) + + +def map_ionq_error(exc: Exception) -> Exception: + if isinstance(exc, RateLimitError): + return ExampleRateLimitError(f"IonQ rate limit: {exc.message}", retry_after=exc.retry_after) + if isinstance(exc, APIError): + return ExampleAPIError( + f"IonQ API {exc.status_code}: {exc.message}", + status_code=exc.status_code, + request_id=exc.request_id, + ) + return exc + + +async def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + logging.getLogger("httpx").setLevel(logging.WARNING) + + extension = ClientExtension( + user_agent_token=SDK_NAME, + default_headers={"X-Example-SDK": SDK_NAME}, + async_event_hooks=(AsyncLoggingHook(),), + error_mapper=map_ionq_error, + ) + + try: + async with IonQClient(extension=extension) as client: + job = await create_job.asyncio(client=client, body=BELL_CIRCUIT) + if job is None: + raise ExampleSDKError("create_job returned no job") + + completed = await async_wait_for_job(client, job.id, timeout=120) + probs = await get_job_probabilities.asyncio(uuid=job.id, client=client) + if probs is None: + raise ExampleSDKError(f"get_job_probabilities returned no data for job {job.id}") + + print() + print("Bell-state job on simulator") + print(f" job_id: {completed.id}") + print(f" status: {completed.status}") + print(f" results: {probs.additional_properties}") + except ExampleSDKError: + logger.exception("downstream SDK call failed") + return 1 + except ValueError as exc: + logger.error("%s", exc) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/ionq_core/models/cost_model.py b/ionq_core/models/cost_model.py index c464277..3348e36 100644 --- a/ionq_core/models/cost_model.py +++ b/ionq_core/models/cost_model.py @@ -4,9 +4,9 @@ from typing import Literal, cast -CostModel = Literal['execution_time', 'quantum_compute_time'] +CostModel = Literal['2QGE_operations', 'execution_time', 'QCT', 'quantum_compute_time'] -COST_MODEL_VALUES: set[CostModel] = { 'execution_time', 'quantum_compute_time', } +COST_MODEL_VALUES: set[CostModel] = { '2QGE_operations', 'execution_time', 'QCT', 'quantum_compute_time', } def check_cost_model(value: str) -> CostModel: if value in COST_MODEL_VALUES: diff --git a/openapi-overlay.yaml b/openapi-overlay.yaml index d371a15..6ce154a 100644 --- a/openapi-overlay.yaml +++ b/openapi-overlay.yaml @@ -1,7 +1,7 @@ overlay: 1.0.0 info: title: ionq-core-python local OpenAPI fixes - version: 0.2.0 + version: 0.3.0 description: | Patches applied to openapi.json before client generation. The upstream spec marks QisCircuitInput.qubits as optional, but the simulator @@ -13,6 +13,9 @@ info: (and extended it to NativeCircuitInput and JsonMultiCircuitInput), so that action was removed. + CostModel is extended with live API values (``QCT``, ``2QGE_operations``) + that the platform returns but the vendored spec has not yet adopted. + actions: - target: $.components.schemas.QisCircuitInput.required remove: true @@ -22,3 +25,12 @@ actions: - circuit - gateset - qubits + - target: $.components.schemas.CostModel.enum + remove: true + - target: $.components.schemas.CostModel + update: + enum: + - quantum_compute_time + - execution_time + - QCT + - 2QGE_operations