Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ionq_core/api/** linguist-generated=true
ionq_core/models/** linguist-generated=true

# Vendored upstream OpenAPI spec.
openapi.json linguist-vendored=true linguist-generated=true
openapi.json linguist-generated=true

# Lockfile.
uv.lock linguist-generated=true
3 changes: 0 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ updates:
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels: ["dependencies"]
groups:
actions:
Expand All @@ -16,7 +15,6 @@ updates:
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels: ["dependencies"]
groups:
python:
Expand All @@ -31,7 +29,6 @@ updates:
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels: ["dependencies"]
groups:
pre-commit:
Expand Down
12 changes: 4 additions & 8 deletions .github/workflows/generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ concurrency:
permissions:
contents: read

env:
UV_FROZEN: true

jobs:
staleness:
runs-on: ubuntu-latest
Expand All @@ -20,16 +23,9 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/setup-uv
- run: uv sync --group regen
- name: Prepare spec
run: |
set -euo pipefail
if [[ -f openapi-overlay.yaml ]]; then
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
else
cp openapi.json /tmp/patched-spec.json
fi
- name: Regenerate client
run: |
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
uv run openapi-python-client generate \
--path /tmp/patched-spec.json \
--meta none \
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/spec-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
# Pinned (keep in sync with CONTRIBUTING.md); never derived from openapi.json,
# so a tampered vendored spec cannot point the check at a mirror that hides it.
# Pinned (tests/test_docs_consistency.py keeps it aligned with DEFAULT_BASE_URL);
# never derived from openapi.json, so a tampered vendored spec cannot point the
# check at a mirror that hides it.
SPEC_URL: https://api.ionq.co/v0.4/api-docs
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,5 @@ jobs:
persist-credentials: false
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
advanced-security: false
annotations: true
25 changes: 5 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ Instructions for AI agents working in this repository. Humans should read [`CONT

```sh
uv sync # canonical; uv.lock is committed and CI runs UV_FROZEN=true
pre-commit install
uvx pre-commit install
```

`uv` is required. Don't use `pip` / `poetry` for dev workflows — they bypass the lockfile.

## Run

```sh
uv run pytest # unit tests; 100% branch coverage gate on hand-written code
uv run pytest
uv run ruff check
uv run ruff format --check # drop --check to apply
uv run ty check ionq_core/
Expand Down Expand Up @@ -51,21 +51,7 @@ When you hit a bug in generated code:

## Regenerating the client

Run exactly what's in [`CONTRIBUTING.md`](CONTRIBUTING.md) and mirrored in [`.github/workflows/generated.yml`](.github/workflows/generated.yml):

```sh
uv sync --group regen
# If v0.4 isn't found, search for the latest API version.
curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
uv run openapi-python-client generate \
--path /tmp/patched-spec.json --meta none \
--config openapi-python-client-config.yaml \
--custom-template-path custom-templates \
--output-path ionq_core --overwrite
```

Commit regenerated files in the same PR as the spec/template/overlay change that produced them.
Run the block in [`CONTRIBUTING.md`](CONTRIBUTING.md#regenerating-the-client) verbatim; CI runs the same invocation via [`generated.yml`](.github/workflows/generated.yml) on every PR. The spec source is `https://api.ionq.co/v0.4/api-docs` (if that version 404s, search for the current one). Commit regenerated files in the same PR as the spec/template/overlay change that produced them.

## Calling generated endpoints

Expand Down Expand Up @@ -104,7 +90,7 @@ Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wir

## Drift sentinels — single edits that fan out

Several values are pinned in multiple files (Python floor, generator/overlay version pins, API base URL, the generated-path set, numeric defaults that appear in both code and docstrings). [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py) is the canonical list of these alignments — when it fails, read the failing assertion to find the peers and update every one in the same PR. Treat that test file as the source of truth; it grows as new pinned values are added.
Several values are pinned in multiple files (Python floor, API base URL, the generated-path set, numeric defaults that appear in both code and docstrings). [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py) is the canonical list of these alignments — when it fails, read the failing assertion to find the peers and update every one in the same PR. Treat that test file as the source of truth; it grows as new pinned values are added.

## CI

Expand All @@ -122,7 +108,7 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu
- Branch off `main`. CODEOWNERS is `@ionq/developer-tools`.
- PR titles become release-notes lines (`gh release create --generate-notes`). Imperative mood, user-facing, no leading ticket number.
- User-visible changes go under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
- Release: bump `pyproject.toml` `[project] version`, promote `[Unreleased]` → `[X.Y.Z]` in `CHANGELOG.md`, tag `vX.Y.Z`. `release.yml` rejects mismatched tag/version pairs and refuses to republish an existing PyPI version.
- Release: bump `pyproject.toml` `[project] version`, promote `[Unreleased]` → `[X.Y.Z]` in `CHANGELOG.md`, tag `vX.Y.Z`.

## Things to avoid (and what to do instead)

Expand All @@ -131,7 +117,6 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu
- **Adding a dependency with `pip install`** → `uv add <pkg>` (or edit `pyproject.toml` and `uv lock`). Confirm the dependency's license before adding: MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause are pre-approved.
- **`Bearer` token examples / `requests` / `aiohttp`** in docs or tests → the library is `httpx`-only and the auth prefix is `apiKey`.
- **Dropping the SPDX header or `# @generated` marker** on regenerated files → if a post-hook regression made this happen, fix `openapi-python-client-config.yaml` rather than re-adding by hand.
- **Lowering the Python floor in one file** → run the local checks in the "Run" section; `tests/test_docs_consistency.py` will list every peer that needs updating in the same commit.
- **Adding NumPy or any new runtime dependency** to `gates.py` → keep it pure-Python.

## Where to look first
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- `Matrix2x2` and `Matrix4x4` are now exported from `ionq_core.gates`; they were already documented as the return types of the gate unitaries.
- `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 `ApiCostModel` (`"QCT"` or `"2QGE_operations"`).
- `clone_job` endpoint (`POST /jobs/{UUID}/clone`) and its `CloneJobPayload` model for resubmitting an existing job with optional overrides.
Expand All @@ -28,10 +29,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- Every `APIError` now carries `retry_after` (parsed and clamped from the `Retry-After` header). Previously only `RateLimitError` exposed it and the value was discarded for other statuses such as 503, where RFC 9110 also allows the header.
- POST requests are no longer retried automatically by the default transport. The API has no idempotency-key mechanism, so replaying `create_job` / `create_session` / `end_session` after an ambiguous gateway 5xx could duplicate billable work; idempotent methods retry as before. Callers that want POST retries must supply their own transport and handle deduplication.
- `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.
- Regenerated with `openapi-python-client` 0.29.0. Generated models now parse timestamps with the standard library (`datetime.fromisoformat`) instead of `dateutil.parser.isoparse`.

### Fixed

- `IonQClient(headers=...)` no longer raises `TypeError`; caller headers are merged beneath the extension defaults and the generated `User-Agent`.
- `cookies` passed to `IonQClient` now reach the async client as well (previously the sync client only).
- `IonQClient()` no longer builds the TLS trust store twice; the SSL context is created once and shared by the sync and async transports.

### Removed

- `get_compiled_file` endpoint (`GET /jobs/{UUID}/circuits/{lang}`) and its `GetCompiledFileLang` enum, removed upstream in favor of `get_job_artifact`. Compiled circuits are now fetched as artifacts by id rather than by `lang` (`"native"` / `"qasm3"`).
Expand Down
14 changes: 4 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency m
git clone https://github.com/ionq/ionq-core-python
cd ionq-core-python
uv sync
pre-commit install
uvx pre-commit install
```

The supported Python floor is set by `requires-python` in `pyproject.toml`; the CI matrix in [`ci.yml`](.github/workflows/ci.yml) is the source of truth for tested interpreters.

## Running checks locally

```sh
uv run pytest # unit tests; 100% branch coverage gate on hand-written code
uv run pytest # unit tests
uv run ruff check # lint
uv run ruff format --check # format check (drop --check to apply)
uv run ty check ionq_core/ # type check
Expand All @@ -64,13 +64,7 @@ To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generate
```sh
uv sync --group regen
curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json

if [ -f openapi-overlay.yaml ]; then
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
else
cp openapi.json /tmp/patched-spec.json
fi

uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
uv run openapi-python-client generate \
--path /tmp/patched-spec.json \
--meta none \
Expand All @@ -88,7 +82,7 @@ Commit the regenerated files alongside the spec or template change that caused t

1. Fork the repository and create a topic branch off `main`.
2. Make your changes; add or update tests for any hand-written code you touch.
3. Run the local checks above and `pre-commit run --all-files`.
3. Run the local checks above and `uvx pre-commit run --all-files`.
4. Push and open a PR against `main`. Fill in the **Summary** and **Test plan** sections of the template.
5. CI must pass: lint, tests across the supported-Python matrix, the generated-code staleness check, `pip-audit`, and `zizmor` when workflow files change. A reviewer from `@ionq/developer-tools` will review.

Expand Down
9 changes: 2 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,11 @@ print(probs.additional_properties)

Each generated endpoint module exposes four callables: `sync`, `sync_detailed`, `asyncio`, and `asyncio_detailed`. The `sync` and `asyncio` variants return the parsed body; the `_detailed` variants return a `Response[T]` with the status code, headers, and parsed body.

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/).
For client options, error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/).

## 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:

```python
import ionq_core
print(ionq_core.__version__)
```
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 `ionq_core.__version__`.

The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core-python/blob/main/CHANGELOG.md).

Expand Down
13 changes: 6 additions & 7 deletions ionq_core/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@
500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5,
max 60s); POST is never retried because the API has no idempotency keys, so a
replay after an ambiguous 5xx could duplicate billable work.

Error handling bounds what it trusts from the server: at most
`MAX_ERROR_BODY_BYTES` decoded bytes of an error body are read, and
``Retry-After`` is clamped to `MAX_RETRY_AFTER` seconds (non-finite values are
discarded) before being exposed on `RateLimitError.retry_after`.
"""

import json
Expand Down Expand Up @@ -174,7 +169,11 @@ def build_transport(
# POST is deliberately not retryable: without idempotency keys, a replay
# after an ambiguous 5xx could duplicate billable jobs.
)
# Build the SSL context once: handing `verify` to each transport would load
# the CA bundle from disk twice per client (create_ssl_context passes an
# ssl.SSLContext through unchanged, so pinned contexts keep their identity).
ctx = httpx.create_ssl_context(verify=verify)
return ErrorRaisingTransport(
RetryTransport(transport=httpx.HTTPTransport(verify=verify), retry=retry),
RetryTransport(transport=httpx.AsyncHTTPTransport(verify=verify), retry=retry),
RetryTransport(transport=httpx.HTTPTransport(verify=ctx), retry=retry),
RetryTransport(transport=httpx.AsyncHTTPTransport(verify=ctx), retry=retry),
)
1 change: 0 additions & 1 deletion ionq_core/api/default/get_job_artifact.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion ionq_core/client.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 13 additions & 21 deletions ionq_core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
IonQError
+-- APIConnectionError # network / DNS failures
| +-- APITimeoutError # request timed out
+-- APIError # HTTP 4xx / 5xx responses
+-- APIError # HTTP 4xx / 5xx responses (carries retry_after)
| +-- BadRequestError # 400
| +-- AuthenticationError # 401
| +-- PermissionDeniedError # 403
| +-- NotFoundError # 404
| +-- RateLimitError # 429 (includes retry_after)
| +-- RateLimitError # 429
| +-- ServerError # 5xx
+-- JobTimeoutError # polling deadline exceeded (ionq_core.polling)
+-- JobFailedError # polled job ended in failure (ionq_core.polling)
```

Example:
Expand Down Expand Up @@ -50,7 +52,9 @@ class IonQError(Exception):
"""Base exception for all IonQ errors.

Catch this to handle any error raised by the library, including connection
failures, API errors, polling timeouts, and job failures.
failures, API errors, polling timeouts, and job failures. The one
exception outside this tree is ``errors.UnexpectedStatus``, raised only
for undocumented status codes when ``raise_on_unexpected_status`` is set.
"""


Expand Down Expand Up @@ -80,6 +84,8 @@ class APIError(IonQError):
or ``None`` if the body could not be read).
message: A human-readable error message extracted from the response,
or a default ``"HTTP <status>"`` string.
retry_after: Seconds to wait before retrying, from the ``Retry-After``
header, or ``None`` if the server did not send a usable one.
request_id: The ``x-request-id`` header from the response, useful for
contacting IonQ support about a specific request.
"""
Expand All @@ -89,11 +95,13 @@ def __init__(
status_code: int,
body: dict | str | None = None,
message: str | None = None,
retry_after: float | None = None,
*,
request_id: str | None = None,
) -> None:
self.status_code = status_code
self.body = body
self.retry_after = retry_after
self.request_id = request_id
self.message = message or f"HTTP {status_code}"
super().__init__(self.message)
Expand Down Expand Up @@ -144,18 +152,6 @@ class RateLimitError(APIError):
attribute into an unbounded wait.
"""

def __init__(
self,
status_code: int = 429,
body: dict | str | None = None,
message: str | None = None,
retry_after: float | None = None,
*,
request_id: str | None = None,
) -> None:
super().__init__(status_code, body, message, request_id=request_id)
self.retry_after = retry_after


class ServerError(APIError):
"""Raised on ``5xx`` server errors.
Expand Down Expand Up @@ -184,9 +180,7 @@ def raise_for_status(
) -> None:
"""Raise an appropriate `APIError` subclass for an HTTP error status.

Does nothing for status codes below 400. For 4xx codes, raises the
specific subclass (e.g. `AuthenticationError` for 401). For 5xx codes
or unrecognized 4xx codes, raises `ServerError` or `APIError` respectively.
Does nothing for status codes below 400.

Args:
status_code: The HTTP status code.
Expand All @@ -207,6 +201,4 @@ def raise_for_status(
if status_code < 400:
return
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ServerError if status_code >= 500 else APIError)
if exc_cls is RateLimitError:
raise RateLimitError(status_code, body, message, retry_after, request_id=request_id)
raise exc_cls(status_code, body, message, request_id=request_id)
raise exc_cls(status_code, body, message, retry_after, request_id=request_id)
Loading
Loading