From 4b1bd809e799295c60a6781f865519e7886b6d7e Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:31:06 -0700 Subject: [PATCH 01/22] Rewrite 0.1.0 changelog entry to reflect actual public API The placeholder entry described features at a high level and predated the final API surface. Replace it with bullets that match what 0.1.0 actually ships: the exported helpers, exception hierarchy, retry behavior, extension API, and supported Python versions. Bumps the release date to 2026-04-28. --- CHANGELOG.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d332d..4bfa6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] - 2026-04-22 +## [0.1.0] - 2026-04-28 ### Added -- Auto-generated Python client from the IonQ OpenAPI v0.4 spec (endpoints, typed models, sync + async) -- `IonQClient` convenience wrapper with API key handling, configurable base URL, and User-Agent -- Retry transport with exponential backoff, idempotency-aware retry, and Retry-After support -- Structured exception hierarchy (`IonQError` -> `APIError` -> `AuthenticationError`, `RateLimitError`, etc.) -- Pagination helpers (`iter_jobs`, `aiter_jobs`, `iter_session_jobs`, `aiter_session_jobs`) -- Job polling helpers (`wait_for_job`, `async_wait_for_job`) with timeout and failure detection -- `SessionManager` for QPU session lifecycle (create, end, status, context manager) -- `ClientExtension` API for downstream SDKs to inject hooks, headers, timeouts, and transport wrappers -- Native gate unitary matrices (`gpi_matrix`, `gpi2_matrix`, `ms_matrix`, `zz_matrix`) -- OpenAPI Overlay for spec workarounds (nullable schemas, missing endpoints, gate fixes) -- 100% test coverage on hand-written code (line + branch) enforced in CI -- CI/CD: lint, type check, tests on Python 3.12-3.14, generated code staleness check, weekly spec drift detection +- `IonQClient` factory with `IONQ_API_KEY` auto-detection, configurable timeouts, and unified sync + async transports. +- Sync and async variants (`sync`, `sync_detailed`, `asyncio`, `asyncio_detailed`) for every endpoint, generated from the IonQ OpenAPI spec via `openapi-python-client`. +- Endpoint coverage: backends, characterizations, jobs (create, list, get, delete, cancel, cost, estimate, compiled file, probabilities, variant histogram/shots/probabilities), sessions (create, list, get, end, list jobs), usage, whoami. +- Structured exception hierarchy rooted at `IonQError`, with `APIError` subclasses for 400, 401, 403, 404, 429, and 5xx responses, plus `APIConnectionError` and `APITimeoutError` for transport failures. `RateLimitError` exposes `retry_after`. +- Automatic retry with exponential backoff and jitter on 429, 500, 502, 503, and 520-529 (default 2 retries), respecting `Retry-After` headers. +- `ClientExtension` configuration bundle for downstream SDKs: `EventHook` / `AsyncEventHook` protocols, `HookTransport`, custom retryable status codes, header injection, transport wrappers, and `error_mapper`. +- Pagination helpers `iter_jobs`, `aiter_jobs`, `iter_session_jobs`, and `aiter_session_jobs` that auto-follow cursor pagination. +- Polling helpers `wait_for_job` and `async_wait_for_job` with exponential backoff, `JobTimeoutError`, and `JobFailedError`. +- `SessionManager` with sync and async context-manager support, optional `max_jobs` / `max_time` / `max_cost` limits, and `SessionManager.from_id` for reconnecting to existing sessions. +- Native trapped-ion gate unitaries `gpi_matrix`, `gpi2_matrix`, `ms_matrix`, and `zz_matrix` as plain Python nested tuples (no NumPy dependency). +- Typed `attrs` request and response models with `from_dict()` / `to_dict()` and an `Unset` sentinel that distinguishes "not provided" from `None`. +- Python 3.12 - 3.14 support, `py.typed` marker, Apache-2.0 license. From 5426608795c029a79e67e383c9d81fdcec02cdc3 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:28:11 -0700 Subject: [PATCH 02/22] Rewrite README for the 0.1.0 release Replaces the prior README with a from-scratch rewrite tailored to an autogenerated Python SDK against a versioned REST API. Structured per the conventions of openai-python, cloudflare-python, kubernetes-client, qiskit, and cirq: tagline, badges, codegen disclosure, alternative high-level SDKs, install, quickstart (Bell circuit), auth, async, errors, retries, pagination, polling, sessions, advanced (hooks / custom transport / error mapping / native gates), SDK-vs-API version table, SemVer carve-outs, requirements, contributing, support, license. --- README.md | 463 +++++++++++++++++++++--------------------------------- 1 file changed, 175 insertions(+), 288 deletions(-) diff --git a/README.md b/README.md index 4e71c5d..67d2b3e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,28 @@ # ionq-core -[![PyPI version](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +Python client for the IonQ Quantum Cloud Platform API. + +[![PyPI](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) +[![Python versions](https://img.shields.io/pypi/pyversions/ionq-core.svg)](https://pypi.org/project/ionq-core/) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) -[![API Docs](https://img.shields.io/badge/docs-API%20reference-blue)](https://ionq.github.io/ionq-core-python/) +[![Docs](https://img.shields.io/badge/docs-ionq.github.io-blue.svg)](https://ionq.github.io/ionq-core-python/) + +`ionq-core` is a typed, async-capable Python client for the [IonQ Quantum Cloud Platform](https://ionq.com) REST API. It covers job submission and lifecycle, results retrieval, backend characterizations, sessions, and usage reporting. The HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); a small set of hand-written extensions wraps it with retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. + +The full API reference for this package is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). -A Python client library for the [IonQ Cloud Platform API](https://docs.ionq.com/), providing full access to IonQ's quantum computing services. Supports both synchronous and asynchronous usage, with typed models for all request and response objects. +## Looking for a higher-level interface? -Auto-generated from the [IonQ OpenAPI specification](https://docs.ionq.com/api-reference/v0.4/introduction) using [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). +`ionq-core` is the low-level HTTP client. Most users should pick the integration that matches their existing stack: + +- **Qiskit** users -> [`qiskit-ionq`](https://pypi.org/project/qiskit-ionq/) +- **Cirq** users -> [`cirq-ionq`](https://pypi.org/project/cirq-ionq/) +- **PennyLane** users -> [`pennylane-ionq`](https://pypi.org/project/pennylane-ionq/) +- **CUDA-Q** users -> IonQ is configured as a backend in NVIDIA CUDA-Q. +- **Multi-vendor users** -> IonQ is reachable via [`qbraid`](https://pypi.org/project/qbraid/). + +Use this package directly if you want programmatic access to the IonQ REST API close to the wire, or if you are building a downstream SDK on top of it. ## Installation @@ -15,403 +30,275 @@ Auto-generated from the [IonQ OpenAPI specification](https://docs.ionq.com/api-r pip install ionq-core ``` -Requires Python 3.12+. +Requires Python 3.12 or newer. + +## Quickstart -## Usage +Submit a Bell-state circuit on the cloud simulator and read the result probabilities: ```python -from ionq_core import IonQClient -from ionq_core.api.backends import get_backends -from ionq_core.api.default import create_job +from ionq_core import IonQClient, wait_for_job +from ionq_core.api.default import create_job, get_job_probabilities from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload -# Uses IONQ_API_KEY env var by default -client = IonQClient() - -# List available backends -backends = get_backends.sync(client=client) -for backend in backends: - print(f"{backend.backend}: {backend.status} ({backend.qubits} qubits)") - -# Submit a quantum circuit -job = create_job.sync( - client=client, - body=CircuitJobCreationPayload.from_dict({ - "type": "ionq.circuit.v1", - "backend": "simulator", - "shots": 1000, - "input": { - "gateset": "qis", - "circuit": [ - {"gate": "h", "targets": [0]}, - {"gate": "cnot", "targets": [0], "controls": [1]}, - ], - }, - }), -) -print(f"Job submitted: {job.id} (status: {job.status})") +client = IonQClient() # reads IONQ_API_KEY from the environment + +body = CircuitJobCreationPayload.from_dict({ + "type": "ionq.circuit.v1", + "backend": "simulator", + "shots": 100, + "input": { + "gateset": "qis", + "circuit": [ + {"gate": "h", "targets": [0]}, + {"gate": "cnot", "control": 0, "target": 1}, + ], + }, +}) + +job = create_job.sync(client=client, body=body) +completed = wait_for_job(client, job.id, timeout=120) +probs = get_job_probabilities.sync(uuid=job.id, client=client) +print(probs.additional_properties) ``` -## Authentication - -IonQ uses API key authentication. Get your key from the [IonQ Cloud Console](https://cloud.ionq.com). - -```python -from ionq_core import IonQClient - -# Option 1: Set the IONQ_API_KEY environment variable (recommended) -client = IonQClient() +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. -# Option 2: Pass the key directly -client = IonQClient(api_key="your-api-key") +## Authentication -# Option 3: Use AuthenticatedClient for full control -from ionq_core import AuthenticatedClient +Authentication uses an API key passed as `Authorization: apiKey ` (note the `apiKey` prefix, not `Bearer`). `IonQClient` reads the key from the `IONQ_API_KEY` environment variable by default: -client = AuthenticatedClient( - base_url="https://api.ionq.co/v0.4", - token="your-api-key", - prefix="apiKey", - auth_header_name="Authorization", -) +```sh +export IONQ_API_KEY="your-api-key" ``` -Some endpoints (e.g., listing backends) do not require authentication. Use `Client` for those: - ```python -from ionq_core import Client +from ionq_core import IonQClient -client = Client(base_url="https://api.ionq.co/v0.4") +client = IonQClient() # IONQ_API_KEY from env +client = IonQClient(api_key="your-key") # explicit +client = IonQClient(base_url="https://api.ionq.co/v0.4") # default base URL ``` +If neither argument nor environment variable is set, `IonQClient()` raises `ValueError`. API keys are issued from your IonQ account. + ## Async usage -Every endpoint has both sync and async variants: +Every endpoint exposes `asyncio` and `asyncio_detailed` callables alongside the synchronous variants. `IonQClient` itself supports both `with` and `async with`: ```python import asyncio from ionq_core import IonQClient from ionq_core.api.backends import get_backends - async def main(): - client = IonQClient() - backends = await get_backends.asyncio(client=client) - for backend in backends: - print(f"{backend.backend}: {backend.status}") - + async with IonQClient() as client: + backends = await get_backends.asyncio(client=client) + print([b.backend for b in backends]) asyncio.run(main()) ``` -Both client types support context managers for proper connection cleanup: - -```python -async with IonQClient() as client: - backends = await get_backends.asyncio(client=client) -``` +The client opens both sync and async httpx transports during construction, so the same `client` instance can be used from both code paths. ## Handling errors -The client raises typed exceptions for all HTTP error responses: +All exceptions inherit from `IonQError`. Concrete subclasses map to HTTP statuses and transport failures: + +```text +IonQError +├── APIConnectionError # network / DNS / TLS failures +│ └── APITimeoutError # request timed out +└── APIError # 4xx / 5xx HTTP responses + ├── BadRequestError # 400 + ├── AuthenticationError # 401 + ├── PermissionDeniedError # 403 + ├── NotFoundError # 404 + ├── RateLimitError # 429 (carries retry_after) + └── ServerError # 5xx +``` ```python -from ionq_core import IonQClient, RateLimitError, AuthenticationError, ServerError - -client = IonQClient() +from ionq_core import AuthenticationError, RateLimitError +from ionq_core.api.default import create_job try: job = create_job.sync(client=client, body=payload) -except AuthenticationError: - print("Invalid API key") +except AuthenticationError as e: + print(f"Invalid API key (request {e.request_id})") except RateLimitError as e: - print(f"Rate limited, retry after {e.retry_after}s") -except ServerError as e: - print(f"Server error: {e.status_code}") + print(f"Rate limited; retry after {e.retry_after}s") ``` -| Status code | Exception | -|---|---| -| 400 | `BadRequestError` | -| 401 | `AuthenticationError` | -| 403 | `PermissionDeniedError` | -| 404 | `NotFoundError` | -| 429 | `RateLimitError` | -| 5xx | `ServerError` | +Every `APIError` carries `status_code`, `body` (parsed JSON or raw string), `message`, and `request_id` from the `x-request-id` response header. Include `request_id` when contacting IonQ support about a specific failure. -All exceptions inherit from `APIError`, which inherits from `IonQError`. Connection failures raise `APIConnectionError`; timeouts raise `APITimeoutError`. +## Retries and timeouts -## Retries - -The client automatically retries transient errors (429, 500, 502, 503, 520-529) and connection/timeout failures with exponential backoff. Default: 2 retries. +Transient failures are retried automatically. The default policy is 2 retries on `429`, `500`, `502`, `503`, and `520`-`529`, with exponential backoff (factor 0.5, jitter 0.5, capped at 60 seconds). `Retry-After` headers are honored. The default request timeout is 60 seconds with a 10-second connect timeout. ```python -client = IonQClient(max_retries=5) # more retries -client = IonQClient(max_retries=0) # disable retries +import httpx +from ionq_core import IonQClient + +client = IonQClient( + max_retries=5, + timeout=httpx.Timeout(30.0, connect=10.0), +) ``` -`Retry-After` headers on 429 responses are respected. +Set `max_retries=0` to disable retries entirely. ## Pagination -Endpoints that return paginated results have auto-pagination helpers: +List endpoints return cursor-paginated responses. `iter_jobs`, `aiter_jobs`, `iter_session_jobs`, and `aiter_session_jobs` follow the cursor automatically and yield individual job objects: ```python -from ionq_core import IonQClient, iter_jobs +from itertools import islice +from ionq_core import iter_jobs -client = IonQClient() -for job in iter_jobs(client, status="completed"): - print(job.id) +for job in islice(iter_jobs(client, status="completed"), 100): + print(job.id, job.backend) ``` -Async: - -```python -from ionq_core import aiter_jobs - -async for job in aiter_jobs(client): - print(job.id) -``` +Each helper accepts the same filters as the underlying `get_jobs` / `get_session_jobs` endpoints (`status`, `target`, `session_id`, `submitter_id`, `limit`). -Also available: `iter_session_jobs` / `aiter_session_jobs`. +## Polling for job completion -## Waiting for job completion +`wait_for_job` polls a job until it reaches a terminal state (`completed`, `failed`, or `canceled`) or the timeout elapses. Polling starts at 1 second and grows by 1.5x to a 30-second cap; the default total timeout is 300 seconds. ```python -from ionq_core import IonQClient, wait_for_job - -client = IonQClient() -job = create_job.sync(client=client, body=payload) -completed_job = wait_for_job(client, job.id, timeout=300) -print(completed_job.status) # "completed" -``` +from ionq_core import wait_for_job, JobTimeoutError, JobFailedError -Polls with exponential backoff (1s initial, 30s max). Raises `JobTimeoutError` on timeout, `JobFailedError` if the job fails. Async: `async_wait_for_job`. - -## Native gate matrices - -Pure-Python unitary matrices for IonQ's native trapped-ion gates, useful for simulation and verification: - -```python -from ionq_core import gpi_matrix, gpi2_matrix, ms_matrix, zz_matrix - -# Phase parameters (phi) are in turns (fractions of 2*pi) -# Interaction parameters (angle) are in units of pi -gpi_matrix(0) # 2x2 Pauli X -gpi2_matrix(0.25) # 2x2 pi/2 rotation -ms_matrix(0, 0) # 4x4 maximally-entangling MS gate (angle defaults to 0.25) -zz_matrix(0.1) # 4x4 ZZ interaction +try: + job = wait_for_job(client, job_id, timeout=300) +except JobTimeoutError as e: + print(f"Polling timed out (last status: {e.last_status})") +except JobFailedError as e: + print(f"Job failed: {e.failure}") ``` -Matrices are returned as nested tuples of complex numbers (no numpy dependency). +Pass `raise_on_failure=False` to receive the failed-job object instead of an exception. The async equivalent is `async_wait_for_job`. -## Session management +## Sessions -`SessionManager` provides a context manager for IonQ priority sessions: +`SessionManager` owns a long-running IonQ QPU session, optionally with limits on jobs, time (in minutes), or cost (in USD): ```python -from ionq_core import IonQClient, SessionManager - -client = IonQClient() +from ionq_core import SessionManager with SessionManager(client, "qpu.aria-1", max_jobs=10, max_time=60) as session: - # submit jobs using session.session_id print(session.session_id) - print(session.status()) - -# Reconnect to an existing session -session = SessionManager.from_id(client, "existing-session-id") + print(session.status()) # "started" + # submit jobs against session.session_id ... +# the session is ended automatically on exit ``` -## Available endpoints - -### Backends - -| Function | Module | Auth | -|---|---|---| -| List backends | `ionq_core.api.backends.get_backends` | No | -| Get a backend | `ionq_core.api.backends.get_backend` | No | - -### Characterizations - -| Function | Module | Auth | -|---|---|---| -| List characterizations | `ionq_core.api.characterizations.get_characterizations_for_backend` | No | -| Get a characterization | `ionq_core.api.characterizations.get_characterization` | Yes | +`SessionManager.from_id(client, session_id)` reconnects to an existing session. The async path uses `async with` and `async_status()`. -### Jobs +## Advanced -| Function | Module | Auth | -|---|---|---| -| Create a job | `ionq_core.api.default.create_job` | Yes | -| List jobs | `ionq_core.api.default.get_jobs` | Yes | -| Get a job | `ionq_core.api.default.get_job` | Yes | -| Delete a job | `ionq_core.api.default.delete_job` | Yes | -| Delete jobs (bulk) | `ionq_core.api.default.delete_jobs` | Yes | -| Cancel a job | `ionq_core.api.default.cancel_job` | Yes | -| Cancel jobs (bulk) | `ionq_core.api.default.cancel_jobs` | Yes | -| Get job cost | `ionq_core.api.default.get_job_cost` | Yes | -| Get compiled circuit | `ionq_core.api.default.get_compiled_file` | Yes | -| Estimate job cost | `ionq_core.api.default.estimate_job_cost` | Yes | -| Get job probabilities | `ionq_core.api.default.get_job_probabilities` | Yes | -| Get variant histogram | `ionq_core.api.default.get_variant_histogram` | Yes | -| Get variant probabilities | `ionq_core.api.default.get_variant_probabilities` | Yes | -| Get variant shots | `ionq_core.api.default.get_variant_shots` | Yes | +### Logging and request hooks -### Sessions +`ClientExtension` bundles hooks, headers, and transport overrides. The `EventHook` and `AsyncEventHook` protocols receive each request and response, and may opt into `on_error`: -| Function | Module | Auth | -|---|---|---| -| Create a session | `ionq_core.api.default.create_session` | Yes | -| List sessions | `ionq_core.api.default.get_sessions` | Yes | -| Get a session | `ionq_core.api.default.get_session` | Yes | -| End a session | `ionq_core.api.default.end_session` | Yes | -| List session jobs | `ionq_core.api.default.get_session_jobs` | Yes | +```python +import httpx +from ionq_core import IonQClient, ClientExtension, EventHook -### Other +class LoggingHook(EventHook): + def on_request(self, request: httpx.Request) -> None: + print(f">>> {request.method} {request.url}") -| Function | Module | Auth | -|---|---|---| -| Who am I | `ionq_core.api.whoami.get_whoami` | Yes | -| Get usage | `ionq_core.api.usage.get_usages` | Yes | + def on_response(self, request: httpx.Request, response: httpx.Response) -> None: + print(f"<<< {response.status_code} {request.url}") -Each endpoint module provides four functions: +client = IonQClient(extension=ClientExtension(event_hooks=(LoggingHook(),))) +``` -- **`sync`** - synchronous call, returns the parsed response -- **`asyncio`** - async call, returns the parsed response -- **`sync_detailed`** - synchronous call, returns `Response[T]` with status code, headers, and parsed body -- **`asyncio_detailed`** - async call, returns `Response[T]` with status code, headers, and parsed body +Hook exceptions are logged and suppressed by default. Set `debug_hooks=True` on `ClientExtension` to re-raise them. -## Models +### Custom HTTP client -All request and response bodies are typed as [attrs](https://www.attrs.org/) classes with `from_dict()` and `to_dict()` methods: +For unusual deployments (proxies, custom CA bundles, mTLS), pass `httpx_args` through `IonQClient` or attach your own `httpx.Client` to the returned client: ```python -from ionq_core.models.backend import Backend +import httpx -# Deserialize from API response dict -backend = Backend.from_dict({"backend": "qpu.aria-1", "status": "available", ...}) +custom = httpx.Client(verify="/path/to/ca-bundle.pem") +client.set_httpx_client(custom) +``` -# Access typed attributes -print(backend.backend) # "qpu.aria-1" -print(backend.qubits) # 25 +For programmatic transport composition (caching, tracing, request signing), set `ClientExtension.transport_wrapper` and `async_transport_wrapper` to wrap the default retry transport. -# Serialize back to dict -data = backend.to_dict() -``` +### Mapping errors for downstream SDKs -Optional fields use the `Unset` sentinel (not `None`) to distinguish between "not provided" and "explicitly null": +`ClientExtension.error_mapper` lets a downstream SDK translate raised exceptions without losing the original chain: ```python -from ionq_core.types import UNSET, Unset +def map_error(exc: Exception) -> Exception: + if isinstance(exc, RateLimitError): + return MyDownstreamRateLimit(str(exc)) + return exc -if not isinstance(backend.characterization_id, Unset): - print(f"Characterization: {backend.characterization_id}") +client = IonQClient(extension=ClientExtension(error_mapper=map_error)) ``` -## Advanced +### Native trapped-ion gates -### Timeouts +`gpi_matrix`, `gpi2_matrix`, `ms_matrix`, and `zz_matrix` return unitary matrices for IonQ's native gates as plain Python nested tuples (no NumPy dependency). Phase parameters are in turns (fractions of 2*pi); interaction angles are in units of pi. ```python -import httpx -from ionq_core import IonQClient +from ionq_core import gpi_matrix, ms_matrix -client = IonQClient(timeout=httpx.Timeout(30.0, connect=10.0)) +gpi_matrix(0.0) # Pauli X +ms_matrix(0.0, 0.0) # maximally-entangling Molmer-Sorensen gate ``` -### Custom headers +## SDK version vs API spec version -```python -client = IonQClient().with_headers({"X-Custom-Header": "value"}) -``` +| `ionq-core` | IonQ REST API | Status | +| ----------- | ------------- | ------- | +| 0.1.x | v0.4 | Current | -### Custom HTTP client +The SDK version follows its own [SemVer 2.0](https://semver.org/spec/v2.0.0.html) cadence, independent of the upstream REST API version. Override the API version with `IonQClient(base_url=...)`. -For full control over the HTTP layer, inject your own `httpx.Client`: +## Versioning -```python -import httpx -from ionq_core import IonQClient +This package follows [SemVer](https://semver.org/spec/v2.0.0.html), with three carve-outs that may ship in minor releases: -custom_httpx = httpx.Client( - base_url="https://api.ionq.co/v0.4", - headers={"Authorization": "apiKey your-key"}, - timeout=60.0, -) - -client = IonQClient(api_key="your-key") -client.set_httpx_client(custom_httpx) -``` +1. Changes that affect static types only, without changing runtime behavior. +2. Changes to library internals that are technically importable but not documented for external use (anything beginning with an underscore, or absent from the API reference). +3. Changes that we do not expect to impact the vast majority of users in practice. -### Accessing raw responses - -Use the `_detailed` variants to get status codes and headers: +Print the installed version with: ```python -from ionq_core.api.whoami import get_whoami - -response = get_whoami.sync_detailed(client=client) -print(response.status_code) # HTTPStatus.OK -print(response.headers) # dict of response headers -print(response.parsed) # Whoami object -print(response.content) # raw bytes +import ionq_core +print(ionq_core.__version__) ``` -## Regenerating the client +The full release history is in [CHANGELOG.md](CHANGELOG.md). -The client is generated from the vendored OpenAPI spec. To regenerate after API changes: +## Requirements -```sh -# Fetch the latest spec -curl -s https://api.ionq.co/v0.4/api-docs -o openapi.json - -# Apply overlay if present (patches spec issues that the generator can't handle) -if [ -f openapi-overlay.yaml ]; then - uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json -else - cp openapi.json /tmp/patched-spec.json -fi - -# Regenerate (custom template preserves hand-written __init__.py exports) -uvx openapi-python-client==0.28.3 generate \ - --path /tmp/patched-spec.json \ - --meta none \ - --config openapi-python-client-config.yaml \ - --custom-template-path custom-templates \ - --output-path ionq_core \ - --overwrite -``` - -### OpenAPI Overlay - -If the upstream spec contains patterns that the code generator cannot handle, fixes are applied via an [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.1.0.html) file (`openapi-overlay.yaml`) using [oas-patch](https://pypi.org/project/oas-patch/). The overlay is declarative, version-controlled, and applied automatically during generation. The vendored `openapi.json` is always the unmodified upstream spec. When the upstream issue is resolved, delete the corresponding action from the overlay (or the entire file) and the pipeline continues to work without it. - -## Development +- Python 3.12, 3.13, or 3.14 +- `httpx >= 0.27, < 0.29` +- `httpx-retries >= 0.5` +- `attrs >= 24.2` +- `python-dateutil >= 2.9` -```sh -uv sync # Install dependencies -uv run pytest # Run tests -uv run ruff check # Lint -uv run ruff format --check # Check formatting -uv run ty check ionq_core/ # Type check -``` - -## Publishing +## Contributing -For a new build to be accepted at PyPI, the version number in pyproject.toml must be incremented. Publishing is handled automatically via trusted publishing on tagged releases: +Most of `ionq_core/` is generated from the OpenAPI spec; pull requests touching files under `ionq_core/api/`, `ionq_core/models/`, or the generated `client.py`, `errors.py`, and `types.py` will be overwritten on the next regeneration. Hand-written extensions, tests, and docs accept contributions freely. -```sh -git tag v0.1.0 -git push origin v0.1.0 -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. -## Getting help +## Support -- **Bug reports and feature requests** - [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) -- **Account, billing, or QPU questions** - [IonQ Support](https://ionq.com/contact) -- **API documentation** - [docs.ionq.com](https://docs.ionq.com/) +- Bug reports and feature requests: [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) +- Security disclosures: see [SECURITY.md](SECURITY.md) +- Account, billing, or hardware-access questions: [ionq.com/contact](https://ionq.com/contact) ## License -Apache-2.0. See [LICENSE](LICENSE) for details. +Apache License 2.0. See [LICENSE](LICENSE). From 4500affd79e8da2c8125cc7459c54ee388d8294e Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:17:23 -0700 Subject: [PATCH 03/22] Rename code owners to developer-tools and link CUDA-Q backend source CODEOWNERS now points at @ionq/developer-tools, matching the team's current name. The README's CUDA-Q bullet now links directly to the IonQServerHelper backend in NVIDIA/cuda-quantum so users can see how the integration is wired. --- .github/CODEOWNERS | 3 +-- README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f5f52a..a9d9a45 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,2 @@ # Default owners for everything in the repo. -* @ionq/sdk-team - +* @ionq/developer-tools diff --git a/README.md b/README.md index 67d2b3e..ffac0d9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The full API reference for this package is published at [ionq.github.io/ionq-cor - **Qiskit** users -> [`qiskit-ionq`](https://pypi.org/project/qiskit-ionq/) - **Cirq** users -> [`cirq-ionq`](https://pypi.org/project/cirq-ionq/) - **PennyLane** users -> [`pennylane-ionq`](https://pypi.org/project/pennylane-ionq/) -- **CUDA-Q** users -> IonQ is configured as a backend in NVIDIA CUDA-Q. +- **CUDA-Q** users -> IonQ is configured as a backend in [NVIDIA CUDA-Q](https://github.com/NVIDIA/cuda-quantum/blob/main/runtime/cudaq/platform/default/rest/helpers/ionq/IonQServerHelper.cpp). - **Multi-vendor users** -> IonQ is reachable via [`qbraid`](https://pypi.org/project/qbraid/). Use this package directly if you want programmatic access to the IonQ REST API close to the wire, or if you are building a downstream SDK on top of it. From 49ac347468b729ae23eac986e7a28f17a5912cf6 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:17:31 -0700 Subject: [PATCH 04/22] Rewrite CONTRIBUTING.md for the 0.1.0 release Replaces the brief stub with a comprehensive contributor guide tailored to a generated Python SDK. Sections cover code of conduct, where to ask questions, bug reporting, scope guidance, the generated-vs-hand-written code structure (with the table of paths, the # @generated marker, and the PR-time staleness check), uv-based development setup, local check commands, integration tests, the exact regeneration command, the PR workflow, SemVer carve-outs, release process, security disclosure, and the CLA contact. --- CONTRIBUTING.md | 208 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 171 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bf4971..03277cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,47 +1,137 @@ # Contributing to ionq-core -Thank you for your interest in contributing to the IonQ Python client. +Thanks for your interest in improving `ionq-core`. This guide covers how to file +bugs, propose changes, set up a development environment, regenerate the client, +and submit pull requests. + +## Code of conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report +unacceptable behavior to . + +## Getting help + +- **Bug reports and feature requests** -> open an issue using the + [bug](.github/ISSUE_TEMPLATE/bug_report.yml) or + [feature](.github/ISSUE_TEMPLATE/feature_request.yml) template. +- **Account, billing, or platform questions** -> . +- **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a + public issue. + +If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, +CUDA-Q, qBraid), see the framework SDKs linked from the +[README](README.md#looking-for-a-higher-level-interface). `ionq-core` is the +low-level HTTP client those SDKs are built on. + +## Reporting bugs + +When opening a bug report, include: + +- A minimal reproduction. +- Expected vs. actual behavior, including any traceback. +- The output of: + ```sh + python -c "import ionq_core, sys, platform; print(ionq_core.__version__, sys.version, platform.platform())" + ``` + +Incomplete reports may be closed and pointed back to this section. + +## Proposing changes + +`ionq-core` is generated from IonQ's OpenAPI specification, and most of the +package is overwritten on every regeneration. Before opening a pull request, +check where your change belongs: + +- **API surface changes** (new endpoints, parameter names, response shapes) -> + these originate in the upstream OpenAPI spec, not this repo. Open an issue + describing the change you want to see. +- **Bugs in generated code** (`ionq_core/api/`, `ionq_core/models/`, or the + four root-level generated files listed below) -> also originate upstream or + in the generator config. File an issue rather than editing the generated + output. +- **Hand-written extensions** (retry transport, exceptions, pagination, + polling, sessions, native gates) -> pull requests welcome. +- **Tests, docs, type hints on hand-written code, tooling** -> pull requests + welcome. + +For non-trivial changes, open an issue first to confirm scope before investing +significant time. + +## Code structure + +The package mixes generated and hand-written code. The boundary is: + +| Path | Status | Edits | +| --- | --- | --- | +| `ionq_core/api/**` | generated | overwritten on regeneration | +| `ionq_core/models/**` | generated | overwritten on regeneration | +| `ionq_core/client.py` | generated | overwritten on regeneration | +| `ionq_core/errors.py` | generated | overwritten on regeneration | +| `ionq_core/types.py` | generated | overwritten on regeneration | +| `ionq_core/__init__.py` | generated from `custom-templates/package_init.py.jinja` | overwritten on regeneration | +| `ionq_core/exceptions.py`, `extensions.py`, `gates.py`, `ionq_client.py`, `pagination.py`, `polling.py`, `session.py` | hand-written | edits welcome | +| `tests/` | hand-written | edits welcome | +| `custom-templates/` | hand-written Jinja overrides | edits welcome | +| `openapi.json` | vendored upstream spec | refreshed by the spec-drift workflow | + +Every generated file starts with the marker: + +```python +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 +# @generated +``` + +The same set of paths is excluded from `ruff` lint rules, `ty` type-check +overrides, and coverage measurement (`pyproject.toml`). The PR-time +[`generated`](.github/workflows/generated.yml) workflow regenerates the client +and fails the build if the result differs from what is committed. ## Development setup +This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency +management; the `uv.lock` file is canonical and CI runs with `UV_FROZEN=true`. + ```sh -git clone https://github.com/ionq/ionq-core-python.git +git clone https://github.com/ionq/ionq-core-python cd ionq-core-python uv sync +pre-commit install ``` -## Running checks +Python 3.12, 3.13, or 3.14 is required. + +## Running checks locally ```sh -uv run pytest # tests -uv run ruff check # lint -uv run ruff format --check # format check -uv run ty check ionq_core/ # type check +uv run pytest # unit tests; 100% branch coverage gate on hand-written code +uv run ruff check # lint +uv run ruff format --check # format check (drop --check to apply) +uv run ty check ionq_core/ # type check ``` -## Code structure +Coverage is measured against the hand-written modules only; the generated +surface is excluded. Tests treat warnings as errors. + +### Integration tests + +Tests under `tests/integration/` hit the live IonQ API. They are excluded by +default and require an API key: + +```sh +export IONQ_API_KEY=... +uv run pytest -m integration --no-cov +``` -Most of the code in `ionq_core/` is **auto-generated** from the IonQ OpenAPI specification. Do not edit generated files directly -- they will be overwritten on regeneration. - -**Generated (do not edit):** -- `ionq_core/api/` -- endpoint modules -- `ionq_core/models/` -- request/response models -- `ionq_core/client.py`, `errors.py`, `types.py` - -**Hand-written (edit freely):** -- `ionq_core/__init__.py` -- public API exports -- `ionq_core/ionq_client.py` -- IonQClient convenience wrapper -- `ionq_core/exceptions.py` -- exception hierarchy -- `ionq_core/extensions.py` -- extension API for downstream SDKs -- `ionq_core/_transport.py` -- retry transport (internal) -- `ionq_core/pagination.py` -- pagination helpers -- `ionq_core/polling.py` -- job polling helpers -- `ionq_core/gates.py` -- native gate matrices -- `ionq_core/session.py` -- session lifecycle manager -- `tests/` -- all tests +CI runs them on a weekly schedule via the +[`integration`](.github/workflows/integration.yml) workflow against a gated +secret; you do not need to run them locally for most contributions. ## Regenerating the client +To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level +generated files, run: + ```sh curl -s https://api.ionq.co/v0.4/api-docs -o openapi.json @@ -60,19 +150,63 @@ uvx openapi-python-client==0.28.3 generate \ --overwrite ``` -## Pull requests +Post-generation hooks defined in `openapi-python-client-config.yaml` patch +`AuthenticatedClient.token` so API keys do not leak into `repr`, prepend the +SPDX and `# @generated` header to every Python file, and run +`ruff check --fix-only` followed by `ruff format`. -- Keep PRs focused on a single change. -- Add tests for new hand-written code. CI enforces 100% branch coverage on all hand-written code. -- CI must pass before merging (lint, tests, type check, generated code staleness check). -- The generated code staleness check on PRs verifies that `ionq_core/` matches what the generator produces. If it fails, regenerate and commit the result. +Commit the regenerated files alongside the spec or template change that caused +them. Spec drift against upstream is checked weekly by +[`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if +`openapi.json` falls behind `https://api.ionq.co/v0.4/api-docs`. -## Contributor License Agreement +## Pull request workflow + +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`. +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 on Python 3.12 / 3.13 / 3.14, the + generated-code staleness check, `pip-audit`, and `zizmor` when workflow + files change. A reviewer from `@ionq/developer-tools` will review. + +There is no enforced commit-message format, but PR titles become release notes +via `gh release create --generate-notes`. Write each title as the line you +would want to see in a changelog: imperative mood, user-facing, no leading +ticket number. -To receive IonQ's CLA, please contact @mjk or email [opensource@ionq.com](mailto:opensource@ionq.com). +User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) +under the next release section, in +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## License +## Versioning + +This project follows [Semantic Versioning 2.0.0](https://semver.org/) with +three carve-outs documented in the [README](README.md#versioning). + +The SDK version is independent of the IonQ API version. The current API +version is `v0.4`; see the SDK <-> API table in the README. + +## Releasing + +Maintainers only: + +1. Bump `version` in `pyproject.toml`. +2. Add a dated section to `CHANGELOG.md`. +3. Tag the commit `vX.Y.Z` on `main` and push the tag. + +The [`release`](.github/workflows/release.yml) workflow verifies that the tag +matches `pyproject.toml` and that the version is not already on PyPI, builds +with `hatchling`, publishes via PyPI Trusted Publishing (OIDC, no API token), +and creates a GitHub Release with auto-generated notes. + +## Security + +Do not file security issues in public. See [SECURITY.md](SECURITY.md). + +## Contributor License Agreement -By submitting a pull request, you represent that you have the right to license your -contribution to IonQ and the community, and agree that your contribution is licensed -under the [Apache License, Version 2.0](LICENSE). +Contributions are accepted under the project's +[Apache 2.0 license](LICENSE). To receive IonQ's CLA, please contact `@mjk` +on GitHub or email . From 0f87f1d1b57155c958c57fa8345577a47db98a4b Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:25:51 -0700 Subject: [PATCH 05/22] Strengthen issue templates and add security contact link Add an affected-area dropdown that mirrors CONTRIBUTING.md's generated/hand-written taxonomy, split bug description into what-happened and expected-behavior, require the reproduction field, add a search-confirmation checkbox to both templates, and route security reports through SECURITY.md instead of public issues. --- .github/ISSUE_TEMPLATE/bug_report.yml | 50 ++++++++++++++++++++-- .github/ISSUE_TEMPLATE/config.yml | 3 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 16 +++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bb6f9e0..2924019 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,11 +3,43 @@ description: Report a bug in ionq-core labels: [bug] body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please search + [existing issues](https://github.com/ionq/ionq-core-python/issues) first, + and review the [reporting guide](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#reporting-bugs) + for what to include. + + - type: dropdown + id: area + attributes: + label: Affected area + description: | + Most of `ionq_core/` is generated from the OpenAPI spec. See + [code structure](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#code-structure). + options: + - Generated client (ionq_core/api/, ionq_core/models/, client.py, errors.py, types.py) + - Hand-written extensions (exceptions, retry, pagination, polling, sessions, native gates) + - API surface / OpenAPI spec (originates upstream) + - Documentation + - Tests or tooling + - Not sure + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, including any error message or traceback. + validations: + required: true + - type: textarea - id: description + id: expected attributes: - label: Description - description: What happened, and what did you expect to happen? + label: What did you expect to happen? validations: required: true @@ -17,6 +49,8 @@ body: label: Reproduction description: Minimal code or steps to reproduce the bug. render: Python + validations: + required: true - type: textarea id: version @@ -25,8 +59,16 @@ body: description: | Paste the output of: ```bash - python -c "import ionq_core; print(ionq_core.__version__); import platform; print(platform.python_version()); print(platform.platform())" + python -c "import ionq_core, sys, platform; print(ionq_core.__version__, sys.version, platform.platform())" ``` render: Text validations: required: true + + - type: checkboxes + id: searched + attributes: + label: Pre-submit checks + options: + - label: I searched existing issues and didn't find a duplicate. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2b52b82..56d5feb 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Report a security vulnerability + url: https://github.com/ionq/ionq-core-python/security/policy + about: Email security@ionq.co. Do not open a public issue. - name: IonQ Support url: https://ionq.com/contact about: For account, billing, or platform questions, contact IonQ support directly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b44b598..9e41df5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,14 @@ description: Suggest a feature for ionq-core labels: [enhancement] body: + - type: markdown + attributes: + value: | + Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) + before opening a new request. For API surface changes (new endpoints, + parameter names, response shapes), see + [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes). + - type: textarea id: description attributes: @@ -10,3 +18,11 @@ body: description: What problem would this solve, and how would you like it to work? validations: required: true + + - type: checkboxes + id: searched + attributes: + label: Pre-submit checks + options: + - label: I searched existing issues and didn't find a duplicate. + required: true From d1082f0de67653a0c0d11a9b6c299a386676f8a6 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:39:28 -0700 Subject: [PATCH 06/22] Write SECURITY.md for the 0.1.0 release --- SECURITY.md | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index f83f3d1..94a6d1b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,49 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in this package, please report it -responsibly by emailing **security@ionq.co**. Do not open a public issue. +**Do not open a public GitHub issue for security vulnerabilities.** -We will acknowledge receipt within 2 business days and aim to provide an -initial assessment within 5 business days. +Report privately through either channel: + +- **GitHub Private Vulnerability Reporting** (preferred): [Report a vulnerability](https://github.com/ionq/ionq-core-python/security/advisories/new). The report is visible only to repository maintainers and people you invite to the advisory. +- **Email**: [security@ionq.co](mailto:security@ionq.co) with the subject line `[ionq-core-python]`. + +Please include enough detail to reproduce the issue, and redact your API key from any logs or response payloads you share. + +## Response Expectations + +- We aim to acknowledge receipt within **3 business days** and follow up with a triage assessment within **10 business days**. +- We follow **coordinated disclosure**. Please do not publicly disclose, share working exploits, or notify third parties until a fix is released and an advisory is published. Our default disclosure window is **90 days** from acknowledgement; we may agree on a shorter or longer timeline depending on severity and where the fix needs to land. +- For confirmed vulnerabilities in this package, we request CVEs through GitHub's CNA via the [repository security advisory](https://docs.github.com/en/code-security/security-advisories) workflow. ## Supported Versions -Only the latest release is supported with security updates. +`ionq-core` is pre-1.0. While the package is in the `0.x` series, **only the latest released minor receives security fixes**. This policy will harden once `1.0` is released. + +| Version | Supported | +| ----------------- | --------- | +| `0.1.x` (latest) | Yes | +| Older `0.x` | No | + +## Scope + +This policy covers the source code in this repository and the `ionq-core` distribution published to PyPI from it. + +### In scope + +- Supply-chain integrity of the published artifact (e.g., compromised release, tampered wheel). +- API-key leakage paths in the SDK (e.g., logging, exception messages, `repr()` output, telemetry). +- Insecure transport defaults (e.g., TLS verification, redirect handling, retry behavior that enables replay). +- Unsafe deserialization, code execution, or SSRF reachable through documented SDK usage. +- CVEs in pinned dependencies that are exploitable through documented SDK usage. + +### Out of scope + +- Vulnerabilities in IonQ's API, quantum cloud backend, control plane, or QPUs. Still email `security@ionq.co`; we will route them internally. +- Issues that require an attacker to already have arbitrary code execution in the user's Python process or write access to their environment or `IONQ_API_KEY`. +- Findings only reproducible against a forked or locally-modified copy of the SDK. +- Theoretical issues without a working proof-of-concept. + +## Credit + +We credit reporters in published advisories by default. If you prefer to remain anonymous, please tell us in your report. From 1dc93fc3d8cd59fb5f0fe43b34ef50d2e65e5bf9 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:56:32 -0700 Subject: [PATCH 07/22] Deduplicate documentation across README, CONTRIBUTING, and templates The generated/hand-written file boundary was restated in five places (README, CONTRIBUTING table, PR template, bug-report dropdown, and pyproject exclusions) with subtle drift, including __init__.py listed inconsistently. CONTRIBUTING.md is now the single source of truth; the other surfaces link to it. The bug-area classification keys off the @generated marker rather than enumerated paths so new generated files don't require doc updates. Other duplications removed: - README "Requirements" section (duplicated pyproject.toml dep pins) - One-row SDK<->API version table (kept the prose that was useful) - Hard-coded "Python 3.12, 3.13, or 3.14" lists (canonical: pyproject + ci.yml) - Standalone "Security" section in CONTRIBUTING (duplicated the Getting help bullet) - @mjk personal handle for the CLA (now opensource@ionq.co) Adds an [Unreleased] section to CHANGELOG so the contribution instruction to "add to the next release section" works. --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 +-- .github/pull_request_template.md | 6 +- CHANGELOG.md | 2 + CONTRIBUTING.md | 87 ++++++++++++--------------- README.md | 18 +----- 5 files changed, 48 insertions(+), 73 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2924019..d9407bb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,11 +16,11 @@ body: attributes: label: Affected area description: | - Most of `ionq_core/` is generated from the OpenAPI spec. See - [code structure](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#code-structure). + See [code structure](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#code-structure) + for the boundary between generated and hand-written code. options: - - Generated client (ionq_core/api/, ionq_core/models/, client.py, errors.py, types.py) - - Hand-written extensions (exceptions, retry, pagination, polling, sessions, native gates) + - Generated client (regenerated from OpenAPI spec) + - Hand-written extensions (retry, pagination, polling, sessions, native gates, etc.) - API surface / OpenAPI spec (originates upstream) - Documentation - Tests or tooling diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 90ad822..b89e42e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,6 +9,6 @@ --- > [!IMPORTANT] -> Most code in `ionq_core/` is auto-generated. Do not edit files under `ionq_core/api/`, -> `ionq_core/models/`, or `ionq_core/client.py`, `errors.py`, `types.py` directly. -> See [CONTRIBUTING.md](../CONTRIBUTING.md#code-structure) for details. +> Most code in `ionq_core/` is auto-generated and overwritten on regeneration. +> See [CONTRIBUTING.md](../CONTRIBUTING.md#code-structure) for which files are +> safe to edit. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfa6bb..2fbcf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + ## [0.1.0] - 2026-04-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03277cb..615d522 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,47 +45,35 @@ check where your change belongs: - **API surface changes** (new endpoints, parameter names, response shapes) -> these originate in the upstream OpenAPI spec, not this repo. Open an issue describing the change you want to see. -- **Bugs in generated code** (`ionq_core/api/`, `ionq_core/models/`, or the - four root-level generated files listed below) -> also originate upstream or - in the generator config. File an issue rather than editing the generated - output. -- **Hand-written extensions** (retry transport, exceptions, pagination, - polling, sessions, native gates) -> pull requests welcome. -- **Tests, docs, type hints on hand-written code, tooling** -> pull requests - welcome. +- **Bugs in generated code** (any file with the `# @generated` marker) -> + these originate upstream or in the generator config. File an issue rather + than editing the generated output. See [Code structure](#code-structure). +- **Hand-written extensions, tests, docs, type hints, tooling** -> pull + requests welcome. For non-trivial changes, open an issue first to confirm scope before investing significant time. ## Code structure -The package mixes generated and hand-written code. The boundary is: - -| Path | Status | Edits | -| --- | --- | --- | -| `ionq_core/api/**` | generated | overwritten on regeneration | -| `ionq_core/models/**` | generated | overwritten on regeneration | -| `ionq_core/client.py` | generated | overwritten on regeneration | -| `ionq_core/errors.py` | generated | overwritten on regeneration | -| `ionq_core/types.py` | generated | overwritten on regeneration | -| `ionq_core/__init__.py` | generated from `custom-templates/package_init.py.jinja` | overwritten on regeneration | -| `ionq_core/exceptions.py`, `extensions.py`, `gates.py`, `ionq_client.py`, `pagination.py`, `polling.py`, `session.py` | hand-written | edits welcome | -| `tests/` | hand-written | edits welcome | -| `custom-templates/` | hand-written Jinja overrides | edits welcome | -| `openapi.json` | vendored upstream spec | refreshed by the spec-drift workflow | - -Every generated file starts with the marker: - -```python -# SPDX-FileCopyrightText: 2026 IonQ, Inc. -# SPDX-License-Identifier: Apache-2.0 -# @generated -``` +`ionq_core/` mixes machine-generated and hand-written code. Files that are +overwritten on every regeneration carry the `# @generated` marker in their +header; never edit them directly: + +- `ionq_core/api/**` and `ionq_core/models/**` +- `ionq_core/client.py`, `errors.py`, `types.py` +- `ionq_core/__init__.py` (regenerated from `custom-templates/package_init.py.jinja`) -The same set of paths is excluded from `ruff` lint rules, `ty` type-check -overrides, and coverage measurement (`pyproject.toml`). The PR-time -[`generated`](.github/workflows/generated.yml) workflow regenerates the client -and fails the build if the result differs from what is committed. +Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, +is hand-written and accepts pull requests. `openapi.json` is the vendored +upstream spec and is refreshed by the regeneration command below. + +The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates +the client and fails the build if the result differs from what is committed. +The mechanically generated paths (api/, models/, client.py, errors.py, +types.py) are also excluded from `ruff` lint, `ty` type checks, and coverage +measurement in `pyproject.toml`. `__init__.py` stays in scope because its +template is ours to maintain. ## Development setup @@ -99,7 +87,8 @@ uv sync pre-commit install ``` -Python 3.12, 3.13, or 3.14 is required. +Python 3.12 or newer is required; the CI matrix is the source of truth for +tested interpreters. ## Running checks locally @@ -133,7 +122,7 @@ To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generated files, run: ```sh -curl -s https://api.ionq.co/v0.4/api-docs -o openapi.json +curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json if [ -f openapi-overlay.yaml ]; then uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json @@ -150,15 +139,19 @@ uvx openapi-python-client==0.28.3 generate \ --overwrite ``` +Keep this command in sync with the +[`generated`](.github/workflows/generated.yml) workflow, which runs the same +invocation on every PR. + Post-generation hooks defined in `openapi-python-client-config.yaml` patch `AuthenticatedClient.token` so API keys do not leak into `repr`, prepend the SPDX and `# @generated` header to every Python file, and run `ruff check --fix-only` followed by `ruff format`. Commit the regenerated files alongside the spec or template change that caused -them. Spec drift against upstream is checked weekly by +them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if -`openapi.json` falls behind `https://api.ionq.co/v0.4/api-docs`. +`openapi.json` falls behind upstream. ## Pull request workflow @@ -167,7 +160,7 @@ them. Spec drift against upstream is checked weekly by 3. Run the local checks above and `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 on Python 3.12 / 3.13 / 3.14, the +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. @@ -182,11 +175,9 @@ under the next release section, in ## Versioning -This project follows [Semantic Versioning 2.0.0](https://semver.org/) with -three carve-outs documented in the [README](README.md#versioning). - -The SDK version is independent of the IonQ API version. The current API -version is `v0.4`; see the SDK <-> API table in the README. +This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) +with three carve-outs documented in the [README](README.md#versioning). The +SDK version is independent of the IonQ API version. ## Releasing @@ -201,12 +192,8 @@ matches `pyproject.toml` and that the version is not already on PyPI, builds with `hatchling`, publishes via PyPI Trusted Publishing (OIDC, no API token), and creates a GitHub Release with auto-generated notes. -## Security - -Do not file security issues in public. See [SECURITY.md](SECURITY.md). - ## Contributor License Agreement Contributions are accepted under the project's -[Apache 2.0 license](LICENSE). To receive IonQ's CLA, please contact `@mjk` -on GitHub or email . +[Apache 2.0 license](LICENSE). To receive IonQ's CLA, email +. diff --git a/README.md b/README.md index ffac0d9..9a3fa8e 100644 --- a/README.md +++ b/README.md @@ -256,11 +256,7 @@ ms_matrix(0.0, 0.0) # maximally-entangling Molmer-Sorensen gate ## SDK version vs API spec version -| `ionq-core` | IonQ REST API | Status | -| ----------- | ------------- | ------- | -| 0.1.x | v0.4 | Current | - -The SDK version follows its own [SemVer 2.0](https://semver.org/spec/v2.0.0.html) cadence, independent of the upstream REST API version. Override the API version with `IonQClient(base_url=...)`. +The SDK version follows its own [SemVer 2.0](https://semver.org/spec/v2.0.0.html) cadence, independent of the upstream REST API version. To pin against a different API, pass an explicit `base_url` to `IonQClient`. ## Versioning @@ -279,19 +275,9 @@ print(ionq_core.__version__) The full release history is in [CHANGELOG.md](CHANGELOG.md). -## Requirements - -- Python 3.12, 3.13, or 3.14 -- `httpx >= 0.27, < 0.29` -- `httpx-retries >= 0.5` -- `attrs >= 24.2` -- `python-dateutil >= 2.9` - ## Contributing -Most of `ionq_core/` is generated from the OpenAPI spec; pull requests touching files under `ionq_core/api/`, `ionq_core/models/`, or the generated `client.py`, `errors.py`, and `types.py` will be overwritten on the next regeneration. Hand-written extensions, tests, and docs accept contributions freely. - -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. +Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. See [CONTRIBUTING.md](CONTRIBUTING.md) for the boundary between generated and hand-written code, development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. ## Support From caa0db04439a799330db60aad421abf8613f4b79 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:03:52 -0700 Subject: [PATCH 08/22] Soft-wrap CONTRIBUTING.md and CODE_OF_CONDUCT.md Replace hard-wrapped prose (~80 cols) with one-paragraph-per-line so future edits don't reflow whole paragraphs. Word counts unchanged; rendered output is identical. --- CODE_OF_CONDUCT.md | 11 +---- CONTRIBUTING.md | 114 ++++++++++++--------------------------------- 2 files changed, 31 insertions(+), 94 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 352392a..582d9e4 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,12 +2,7 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. ## Our Standards @@ -27,9 +22,7 @@ Examples of unacceptable behavior: ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the project maintainers at support@ionq.co. All complaints will be -reviewed and investigated. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers at support@ionq.co. All complaints will be reviewed and investigated. ## Attribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615d522..628bdac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,18 @@ # Contributing to ionq-core -Thanks for your interest in improving `ionq-core`. This guide covers how to file -bugs, propose changes, set up a development environment, regenerate the client, -and submit pull requests. +Thanks for your interest in improving `ionq-core`. This guide covers how to file bugs, propose changes, set up a development environment, regenerate the client, and submit pull requests. ## Code of conduct -This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report -unacceptable behavior to . +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unacceptable behavior to . ## Getting help -- **Bug reports and feature requests** -> open an issue using the - [bug](.github/ISSUE_TEMPLATE/bug_report.yml) or - [feature](.github/ISSUE_TEMPLATE/feature_request.yml) template. +- **Bug reports and feature requests** -> open an issue using the [bug](.github/ISSUE_TEMPLATE/bug_report.yml) or [feature](.github/ISSUE_TEMPLATE/feature_request.yml) template. - **Account, billing, or platform questions** -> . -- **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a - public issue. +- **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a public issue. -If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, -CUDA-Q, qBraid), see the framework SDKs linked from the -[README](README.md#looking-for-a-higher-level-interface). `ionq-core` is the -low-level HTTP client those SDKs are built on. +If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, CUDA-Q, qBraid), see the framework SDKs linked from the [README](README.md#looking-for-a-higher-level-interface). `ionq-core` is the low-level HTTP client those SDKs are built on. ## Reporting bugs @@ -38,47 +29,29 @@ Incomplete reports may be closed and pointed back to this section. ## Proposing changes -`ionq-core` is generated from IonQ's OpenAPI specification, and most of the -package is overwritten on every regeneration. Before opening a pull request, -check where your change belongs: +`ionq-core` is generated from IonQ's OpenAPI specification, and most of the package is overwritten on every regeneration. Before opening a pull request, check where your change belongs: -- **API surface changes** (new endpoints, parameter names, response shapes) -> - these originate in the upstream OpenAPI spec, not this repo. Open an issue - describing the change you want to see. -- **Bugs in generated code** (any file with the `# @generated` marker) -> - these originate upstream or in the generator config. File an issue rather - than editing the generated output. See [Code structure](#code-structure). -- **Hand-written extensions, tests, docs, type hints, tooling** -> pull - requests welcome. +- **API surface changes** (new endpoints, parameter names, response shapes) -> these originate in the upstream OpenAPI spec, not this repo. Open an issue describing the change you want to see. +- **Bugs in generated code** (any file with the `# @generated` marker) -> these originate upstream or in the generator config. File an issue rather than editing the generated output. See [Code structure](#code-structure). +- **Hand-written extensions, tests, docs, type hints, tooling** -> pull requests welcome. -For non-trivial changes, open an issue first to confirm scope before investing -significant time. +For non-trivial changes, open an issue first to confirm scope before investing significant time. ## Code structure -`ionq_core/` mixes machine-generated and hand-written code. Files that are -overwritten on every regeneration carry the `# @generated` marker in their -header; never edit them directly: +`ionq_core/` mixes machine-generated and hand-written code. Files that are overwritten on every regeneration carry the `# @generated` marker in their header; never edit them directly: - `ionq_core/api/**` and `ionq_core/models/**` - `ionq_core/client.py`, `errors.py`, `types.py` - `ionq_core/__init__.py` (regenerated from `custom-templates/package_init.py.jinja`) -Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, -is hand-written and accepts pull requests. `openapi.json` is the vendored -upstream spec and is refreshed by the regeneration command below. +Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, is hand-written and accepts pull requests. `openapi.json` is the vendored upstream spec and is refreshed by the regeneration command below. -The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates -the client and fails the build if the result differs from what is committed. -The mechanically generated paths (api/, models/, client.py, errors.py, -types.py) are also excluded from `ruff` lint, `ty` type checks, and coverage -measurement in `pyproject.toml`. `__init__.py` stays in scope because its -template is ours to maintain. +The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates the client and fails the build if the result differs from what is committed. The mechanically generated paths (api/, models/, client.py, errors.py, types.py) are also excluded from `ruff` lint, `ty` type checks, and coverage measurement in `pyproject.toml`. `__init__.py` stays in scope because its template is ours to maintain. ## Development setup -This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency -management; the `uv.lock` file is canonical and CI runs with `UV_FROZEN=true`. +This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency management; the `uv.lock` file is canonical and CI runs with `UV_FROZEN=true`. ```sh git clone https://github.com/ionq/ionq-core-python @@ -87,8 +60,7 @@ uv sync pre-commit install ``` -Python 3.12 or newer is required; the CI matrix is the source of truth for -tested interpreters. +Python 3.12 or newer is required; the CI matrix is the source of truth for tested interpreters. ## Running checks locally @@ -99,27 +71,22 @@ uv run ruff format --check # format check (drop --check to apply) uv run ty check ionq_core/ # type check ``` -Coverage is measured against the hand-written modules only; the generated -surface is excluded. Tests treat warnings as errors. +Coverage is measured against the hand-written modules only; the generated surface is excluded. Tests treat warnings as errors. ### Integration tests -Tests under `tests/integration/` hit the live IonQ API. They are excluded by -default and require an API key: +Tests under `tests/integration/` hit the live IonQ API. They are excluded by default and require an API key: ```sh export IONQ_API_KEY=... uv run pytest -m integration --no-cov ``` -CI runs them on a weekly schedule via the -[`integration`](.github/workflows/integration.yml) workflow against a gated -secret; you do not need to run them locally for most contributions. +CI runs them on a weekly schedule via the [`integration`](.github/workflows/integration.yml) workflow against a gated secret; you do not need to run them locally for most contributions. ## Regenerating the client -To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level -generated files, run: +To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generated files, run: ```sh curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json @@ -139,45 +106,27 @@ uvx openapi-python-client==0.28.3 generate \ --overwrite ``` -Keep this command in sync with the -[`generated`](.github/workflows/generated.yml) workflow, which runs the same -invocation on every PR. +Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. -Post-generation hooks defined in `openapi-python-client-config.yaml` patch -`AuthenticatedClient.token` so API keys do not leak into `repr`, prepend the -SPDX and `# @generated` header to every Python file, and run -`ruff check --fix-only` followed by `ruff format`. +Post-generation hooks defined in `openapi-python-client-config.yaml` patch `AuthenticatedClient.token` so API keys do not leak into `repr`, prepend the SPDX and `# @generated` header to every Python file, and run `ruff check --fix-only` followed by `ruff format`. -Commit the regenerated files alongside the spec or template change that caused -them. Spec drift is checked weekly by -[`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if -`openapi.json` falls behind upstream. +Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream. ## Pull request workflow 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`. -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. +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. -There is no enforced commit-message format, but PR titles become release notes -via `gh release create --generate-notes`. Write each title as the line you -would want to see in a changelog: imperative mood, user-facing, no leading -ticket number. +There is no enforced commit-message format, but PR titles become release notes via `gh release create --generate-notes`. Write each title as the line you would want to see in a changelog: imperative mood, user-facing, no leading ticket number. -User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) -under the next release section, in -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) under the next release section, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. ## Versioning -This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) -with three carve-outs documented in the [README](README.md#versioning). The -SDK version is independent of the IonQ API version. +This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) with three carve-outs documented in the [README](README.md#versioning). The SDK version is independent of the IonQ API version. ## Releasing @@ -187,13 +136,8 @@ Maintainers only: 2. Add a dated section to `CHANGELOG.md`. 3. Tag the commit `vX.Y.Z` on `main` and push the tag. -The [`release`](.github/workflows/release.yml) workflow verifies that the tag -matches `pyproject.toml` and that the version is not already on PyPI, builds -with `hatchling`, publishes via PyPI Trusted Publishing (OIDC, no API token), -and creates a GitHub Release with auto-generated notes. +The [`release`](.github/workflows/release.yml) workflow verifies that the tag matches `pyproject.toml` and that the version is not already on PyPI, builds with `hatchling`, publishes via PyPI Trusted Publishing (OIDC, no API token), and creates a GitHub Release with auto-generated notes. ## Contributor License Agreement -Contributions are accepted under the project's -[Apache 2.0 license](LICENSE). To receive IonQ's CLA, email -. +Contributions are accepted under the project's [Apache 2.0 license](LICENSE). To receive IonQ's CLA, email . From 63aa6370f9c74f78e450a6f89dc275df98d252d2 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:24:41 -0700 Subject: [PATCH 09/22] Tighten 0.1.0 docs and templates against peer-SDK conventions - Rewrite README relative links as absolute GitHub URLs so they resolve on PyPI (the renderer does not follow relative paths). - Add a Safe Harbor clause to SECURITY.md committing to no legal action against good-faith researchers. - Add `[Unreleased]` / `[0.1.0]` link reference footers to CHANGELOG.md so the version tokens resolve. - Replace the abbreviated CODE_OF_CONDUCT.md with the verbatim Contributor Covenant 2.1 and route reports to ; sync the same address in CONTRIBUTING.md. - Make the "What did you expect?" field optional in the bug-report template - tracebacks usually make it redundant. --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- CHANGELOG.md | 3 ++ CODE_OF_CONDUCT.md | 78 ++++++++++++++++++++++----- CONTRIBUTING.md | 2 +- README.md | 10 ++-- SECURITY.md | 12 +++++ 6 files changed, 88 insertions(+), 20 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d9407bb..a8155f4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,8 +40,7 @@ body: id: expected attributes: label: What did you expect to happen? - validations: - required: true + description: Optional - skip if a traceback or error message above already shows the problem. - type: textarea id: reproduction diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbcf8d..9987365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Native trapped-ion gate unitaries `gpi_matrix`, `gpi2_matrix`, `ms_matrix`, and `zz_matrix` as plain Python nested tuples (no NumPy dependency). - Typed `attrs` request and response models with `from_dict()` / `to_dict()` and an `Unset` sentinel that distinguishes "not provided" from `None`. - Python 3.12 - 3.14 support, `py.typed` marker, Apache-2.0 license. + +[Unreleased]: https://github.com/ionq/ionq-core-python/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ionq/ionq-core-python/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 582d9e4..3ae23e3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -4,26 +4,80 @@ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + ## Our Standards -Examples of behavior that contributes to a positive environment: +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. -Examples of unacceptable behavior: +## Scope -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information without explicit permission -- Other conduct which could reasonably be considered inappropriate +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers at support@ionq.co. All complaints will be reviewed and investigated. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 628bdac..698fc19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in improving `ionq-core`. This guide covers how to file ## Code of conduct -This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unacceptable behavior to . +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unacceptable behavior to . ## Getting help diff --git a/README.md b/README.md index 9a3fa8e..2556d01 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Python client for the IonQ Quantum Cloud Platform API. [![PyPI](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) [![Python versions](https://img.shields.io/pypi/pyversions/ionq-core.svg)](https://pypi.org/project/ionq-core/) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/ionq/ionq-core-python/blob/main/LICENSE) [![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-ionq.github.io-blue.svg)](https://ionq.github.io/ionq-core-python/) @@ -273,18 +273,18 @@ import ionq_core print(ionq_core.__version__) ``` -The full release history is in [CHANGELOG.md](CHANGELOG.md). +The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core-python/blob/main/CHANGELOG.md). ## Contributing -Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. See [CONTRIBUTING.md](CONTRIBUTING.md) for the boundary between generated and hand-written code, development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. +Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. See [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md) for the boundary between generated and hand-written code, development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. ## Support - Bug reports and feature requests: [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) -- Security disclosures: see [SECURITY.md](SECURITY.md) +- Security disclosures: see [SECURITY.md](https://github.com/ionq/ionq-core-python/blob/main/SECURITY.md) - Account, billing, or hardware-access questions: [ionq.com/contact](https://ionq.com/contact) ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](https://github.com/ionq/ionq-core-python/blob/main/LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 94a6d1b..a5a45cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,6 +17,18 @@ Please include enough detail to reproduce the issue, and redact your API key fro - We follow **coordinated disclosure**. Please do not publicly disclose, share working exploits, or notify third parties until a fix is released and an advisory is published. Our default disclosure window is **90 days** from acknowledgement; we may agree on a shorter or longer timeline depending on severity and where the fix needs to land. - For confirmed vulnerabilities in this package, we request CVEs through GitHub's CNA via the [repository security advisory](https://docs.github.com/en/code-security/security-advisories) workflow. +## Safe Harbor + +When conducting security research consistent with this policy, we consider your research to be authorized and lawful. Specifically: + +- We will not initiate or support legal action against you for accidental, good-faith violations of this policy under applicable anti-hacking laws (such as the U.S. Computer Fraud and Abuse Act). +- We will not bring a claim against you for circumvention of technical controls under relevant anti-circumvention laws (such as DMCA section 1201). +- If a third party initiates legal action against you for activities conducted in good-faith compliance with this policy, we will take steps to make it known that your actions were authorized. + +In return, we ask that you comply with all applicable laws, make reasonable efforts to avoid privacy violations, service disruption, and destruction of data, limit testing to your own account or accounts you control, and use the channels above to discuss vulnerabilities with us. + +If you are unsure whether a planned activity is consistent with this policy, contact before proceeding. Safe harbor applies only to claims within IonQ's control; this policy does not bind independent third parties. + ## Supported Versions `ionq-core` is pre-1.0. While the package is in the `0.x` series, **only the latest released minor receives security fixes**. This policy will harden once `1.0` is released. From 151c4235c68e6d0d9876fc7c2f899a2a713e997a Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:17:35 -0700 Subject: [PATCH 10/22] Pin README copy to runtime constants and align doc drift - Bump pre-commit ruff to v0.15.12 so it matches the dev dep. - Drop "Quantum" from the README one-liner to align with pyproject and the auto-generated package docstring. - Match LoggingHook arrow style between README and extensions.py. - Reword CONTRIBUTING.md to describe the ty config accurately (rule override for api/ + models/, not a path exclusion). - Fill in 1.5x growth and default total wait in the polling module docstring. - Extract _DEFAULT_BASE_URL so the integration test can import the production URL once instead of redeclaring it. - Decouple unit-test placeholder URLs from the production base. - Add tests/test_docs_consistency.py to pin README copy to the retry, timeout, and polling constants so future drift fails CI. --- .pre-commit-config.yaml | 2 +- CONTRIBUTING.md | 2 +- README.md | 8 ++++---- ionq_core/ionq_client.py | 3 ++- ionq_core/polling.py | 4 ++-- tests/integration/test_backends.py | 5 ++--- tests/test_docs_consistency.py | 31 ++++++++++++++++++++++++++++++ tests/test_extensions.py | 2 +- tests/test_transport.py | 2 +- 9 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 tests/test_docs_consistency.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 514241a..575b9a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: gitleaks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.15.12 hooks: - id: ruff-check args: [--fix] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 698fc19..b9d50a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ For non-trivial changes, open an issue first to confirm scope before investing s Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, is hand-written and accepts pull requests. `openapi.json` is the vendored upstream spec and is refreshed by the regeneration command below. -The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates the client and fails the build if the result differs from what is committed. The mechanically generated paths (api/, models/, client.py, errors.py, types.py) are also excluded from `ruff` lint, `ty` type checks, and coverage measurement in `pyproject.toml`. `__init__.py` stays in scope because its template is ours to maintain. +The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates the client and fails the build if the result differs from what is committed. The mechanically generated paths (api/, models/, client.py, errors.py, types.py) are excluded from `ruff` lint and coverage measurement in `pyproject.toml`; `ty` runs against the whole package but with `invalid-argument-type` loosened for `api/` and `models/`, where it triggers on spec-driven `attrs` models. `__init__.py` stays in scope for all three because its template is ours to maintain. ## Development setup diff --git a/README.md b/README.md index 2556d01..1e0ef8e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ionq-core -Python client for the IonQ Quantum Cloud Platform API. +Python client for the IonQ Cloud Platform API. [![PyPI](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) [![Python versions](https://img.shields.io/pypi/pyversions/ionq-core.svg)](https://pypi.org/project/ionq-core/) @@ -8,7 +8,7 @@ Python client for the IonQ Quantum Cloud Platform API. [![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-ionq.github.io-blue.svg)](https://ionq.github.io/ionq-core-python/) -`ionq-core` is a typed, async-capable Python client for the [IonQ Quantum Cloud Platform](https://ionq.com) REST API. It covers job submission and lifecycle, results retrieval, backend characterizations, sessions, and usage reporting. The HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); a small set of hand-written extensions wraps it with retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. +`ionq-core` is a typed, async-capable Python client for the [IonQ Cloud Platform](https://ionq.com) REST API. It covers job submission and lifecycle, results retrieval, backend characterizations, sessions, and usage reporting. The HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); a small set of hand-written extensions wraps it with retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. The full API reference for this package is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). @@ -207,10 +207,10 @@ from ionq_core import IonQClient, ClientExtension, EventHook class LoggingHook(EventHook): def on_request(self, request: httpx.Request) -> None: - print(f">>> {request.method} {request.url}") + print(f"--> {request.method} {request.url}") def on_response(self, request: httpx.Request, response: httpx.Response) -> None: - print(f"<<< {response.status_code} {request.url}") + print(f"<-- {response.status_code} {request.url}") client = IonQClient(extension=ClientExtension(event_hooks=(LoggingHook(),))) ``` diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 92a9c64..0daf56d 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -28,6 +28,7 @@ except PackageNotFoundError: __version__ = "0.0.0" +_DEFAULT_BASE_URL = "https://api.ionq.co/v0.4" _DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0) _AUTH_PREFIX = "apiKey" _AUTH_HEADER = "Authorization" @@ -36,7 +37,7 @@ def IonQClient( *, api_key: str | None = None, - base_url: str = "https://api.ionq.co/v0.4", + base_url: str = _DEFAULT_BASE_URL, max_retries: int | None = None, timeout: httpx.Timeout | None = None, additional_user_agent: str | None = None, diff --git a/ionq_core/polling.py b/ionq_core/polling.py index 6c8ae5a..4294e08 100644 --- a/ionq_core/polling.py +++ b/ionq_core/polling.py @@ -5,8 +5,8 @@ After submitting a job, use `wait_for_job` (or `async_wait_for_job`) to block until it reaches a terminal state (completed, failed, or canceled). -Polling uses exponential backoff from 1 second up to a 30-second maximum -interval. +Polling starts at `_DEFAULT_INTERVAL` and grows by 1.5x each iteration up +to `_MAX_INTERVAL`; the default total wait is `_DEFAULT_TIMEOUT` seconds. Example: ```python diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py index 9da8158..e46d9f2 100644 --- a/tests/integration/test_backends.py +++ b/tests/integration/test_backends.py @@ -4,16 +4,15 @@ from ionq_core import Client from ionq_core.api.backends import get_backend, get_backends +from ionq_core.ionq_client import _DEFAULT_BASE_URL pytestmark = pytest.mark.integration -BASE_URL = "https://api.ionq.co/v0.4" - @pytest.fixture(scope="module") def backends(): # Backends listing is unauthenticated - no API key needed. - return get_backends.sync(client=Client(base_url=BASE_URL)) + return get_backends.sync(client=Client(base_url=_DEFAULT_BASE_URL)) def test_list_returns_backends(backends): diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py new file mode 100644 index 0000000..c507b2d --- /dev/null +++ b/tests/test_docs_consistency.py @@ -0,0 +1,31 @@ +"""Pin README copy to runtime constants so doc drift is caught in CI.""" + +from pathlib import Path + +from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES +from ionq_core.ionq_client import _DEFAULT_TIMEOUT +from ionq_core.polling import _DEFAULT_INTERVAL, _MAX_INTERVAL +from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT + +README = (Path(__file__).parent.parent / "README.md").read_text() + + +class TestREADMEMentionsCurrentConstants: + def test_retry_status_codes_mentioned(self): + for code in (429, 500, 502, 503): + assert str(code) in README + assert "520" in README and "529" in README + assert frozenset({429, 500, 502, 503, *range(520, 530)}) == RETRYABLE_STATUS_CODES + + def test_default_max_retries(self): + assert f"{DEFAULT_MAX_RETRIES} retries" in README + + def test_default_timeout(self): + assert f"{int(_DEFAULT_TIMEOUT.read)} seconds" in README + assert f"{int(_DEFAULT_TIMEOUT.connect)}-second connect" in README + + def test_polling_defaults(self): + assert f"{int(_DEFAULT_INTERVAL)} second " in README + assert f"{int(_MAX_INTERVAL)}-second cap" in README + assert f"{int(_POLL_DEFAULT_TIMEOUT)} seconds" in README + assert "1.5x" in README diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e7d3075..edcb3bd 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,7 +11,7 @@ HookTransport, ) -_BACKENDS_URL = "https://api.ionq.co/v0.4/backends" +_BACKENDS_URL = "https://test.invalid/backends" class RecordingHook: diff --git a/tests/test_transport.py b/tests/test_transport.py index 8e38971..d35e490 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -12,7 +12,7 @@ ServerError, ) -_URL = "https://api.ionq.co/v0.4/backends" +_URL = "https://test.invalid/backends" class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): From 14b072ee5977e4a18a5bed53d913dc9817f8c92a Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:55:50 -0700 Subject: [PATCH 11/22] Pin more drift surfaces in CI and dedupe workflow setup Extends the docs-consistency gate to cover the retry backoff trio, default base URL, auth prefix, exception classes, Python floor across pyproject/CI/.python-version/ruff/ty, generated-paths exclusions, the SessionManager example backend, and the package description across pyproject/README/__init__.py/template. Adds a setup-uv composite action so the pinned SHA and Python default live in one file; routes all five setup-using workflows through it. Names polling's growth factor, hoists the test BASE_URL into conftest, switches the SPDX post-hook to stamp \$(date +%Y), and unifies the package description on "A client library for accessing IonQ Cloud Platform API". --- .github/actions/setup-uv/action.yml | 20 ++++ .github/workflows/ci.yml | 8 +- .github/workflows/docs.yml | 4 +- .github/workflows/generated.yml | 5 +- .github/workflows/integration.yml | 5 +- .github/workflows/release.yml | 5 +- CONTRIBUTING.md | 11 +- README.md | 2 +- custom-templates/package_init.py.jinja | 3 +- ionq_core/polling.py | 10 +- openapi-python-client-config.yaml | 2 +- pyproject.toml | 2 +- tests/conftest.py | 6 +- tests/test_docs_consistency.py | 140 +++++++++++++++++++++++-- tests/test_extensions.py | 3 +- tests/test_session.py | 3 +- tests/test_transport.py | 3 +- 17 files changed, 183 insertions(+), 49 deletions(-) create mode 100644 .github/actions/setup-uv/action.yml diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml new file mode 100644 index 0000000..27e8d07 --- /dev/null +++ b/.github/actions/setup-uv/action.yml @@ -0,0 +1,20 @@ +name: Set up uv with Python +description: Install uv and Python with the project's pinned defaults. + +inputs: + python-version: + description: Python version to install. Defaults to the project's floor. + required: false + default: "3.12" + enable-cache: + description: Pass-through to astral-sh/setup-uv enable-cache. + required: false + default: "false" + +runs: + using: composite + steps: + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: ${{ inputs.python-version }} + enable-cache: ${{ inputs.enable-cache }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ef4ab..4bca6c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" enable-cache: ${{ github.event_name == 'push' }} - run: uv sync - run: uv run ruff check @@ -43,7 +42,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: python-version: ${{ matrix.python-version }} enable-cache: ${{ github.event_name == 'push' }} @@ -57,8 +56,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" enable-cache: ${{ github.event_name == 'push' }} - run: uvx pip-audit --require-hashes --strict -r <(uv pip compile pyproject.toml --generate-hashes) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dc9fe57..286f799 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,9 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" + - uses: ./.github/actions/setup-uv - run: uv sync - run: uv run pdoc -o docs/ -d google ionq_core - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index 1a7dfe6..5b2aa4f 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -18,10 +18,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" - enable-cache: false + - uses: ./.github/actions/setup-uv - name: Prepare spec run: | set -euo pipefail diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index a3ae931..2a9f2ab 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -24,10 +24,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" - enable-cache: true + enable-cache: "true" - run: uv sync - name: Run integration tests env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5226bfb..68a6a60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,10 +41,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" - enable-cache: false + - uses: ./.github/actions/setup-uv - run: uv build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9d50a2..870f05a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,16 +16,7 @@ If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, CUDA-Q ## Reporting bugs -When opening a bug report, include: - -- A minimal reproduction. -- Expected vs. actual behavior, including any traceback. -- The output of: - ```sh - python -c "import ionq_core, sys, platform; print(ionq_core.__version__, sys.version, platform.platform())" - ``` - -Incomplete reports may be closed and pointed back to this section. +Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.yml) and fill in every required field, including the version-info one-liner the template asks for. Incomplete reports may be closed. ## Proposing changes diff --git a/README.md b/README.md index 1e0ef8e..3101817 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ionq-core -Python client for the IonQ Cloud Platform API. +A client library for accessing IonQ Cloud Platform API. [![PyPI](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) [![Python versions](https://img.shields.io/pypi/pyversions/ionq-core.svg)](https://pypi.org/project/ionq-core/) diff --git a/custom-templates/package_init.py.jinja b/custom-templates/package_init.py.jinja index d05c1c0..8b1391d 100644 --- a/custom-templates/package_init.py.jinja +++ b/custom-templates/package_init.py.jinja @@ -1,6 +1,7 @@ {% from "helpers.jinja" import safe_docstring %} {% set modules = ["exceptions", "extensions", "gates", "ionq_client", "pagination", "polling", "session"] %} -{{ safe_docstring(package_description) }} +{# Pinned: keep this in sync with pyproject.toml and README.md (test_docs_consistency.py enforces). #} +{{ safe_docstring("A client library for accessing IonQ Cloud Platform API") }} from . import {{ modules | join(", ") }} from .client import AuthenticatedClient, Client # noqa: F401 {% for m in modules %} diff --git a/ionq_core/polling.py b/ionq_core/polling.py index 4294e08..3f299a3 100644 --- a/ionq_core/polling.py +++ b/ionq_core/polling.py @@ -5,8 +5,9 @@ After submitting a job, use `wait_for_job` (or `async_wait_for_job`) to block until it reaches a terminal state (completed, failed, or canceled). -Polling starts at `_DEFAULT_INTERVAL` and grows by 1.5x each iteration up -to `_MAX_INTERVAL`; the default total wait is `_DEFAULT_TIMEOUT` seconds. +Polling starts at `_DEFAULT_INTERVAL` and grows by `_BACKOFF_FACTOR` each +iteration up to `_MAX_INTERVAL`; the default total wait is +`_DEFAULT_TIMEOUT` seconds. Example: ```python @@ -43,6 +44,7 @@ _DEFAULT_INTERVAL = 1.0 _DEFAULT_TIMEOUT = 300.0 _MAX_INTERVAL = 30.0 +_BACKOFF_FACTOR = 1.5 class JobTimeoutError(IonQError): @@ -131,7 +133,7 @@ def wait_for_job( if time.monotonic() >= deadline: raise JobTimeoutError(job_id, timeout, job.status) time.sleep(max(0, min(interval, deadline - time.monotonic()))) - interval = min(interval * 1.5, _MAX_INTERVAL) + interval = min(interval * _BACKOFF_FACTOR, _MAX_INTERVAL) async def async_wait_for_job( @@ -172,4 +174,4 @@ async def async_wait_for_job( if time.monotonic() >= deadline: raise JobTimeoutError(job_id, timeout, job.status) await asyncio.sleep(max(0, min(interval, deadline - time.monotonic()))) - interval = min(interval * 1.5, _MAX_INTERVAL) + interval = min(interval * _BACKOFF_FACTOR, _MAX_INTERVAL) diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index d7951dc..d89634f 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,6 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - - "perl -0777 -pi -e 's/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: 2026 IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" + - "YEAR=$(date +%Y) perl -0777 -pi -e 's/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $ENV{YEAR} IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." diff --git a/pyproject.toml b/pyproject.toml index 812b584..a6fd5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ionq-core" version = "0.1.0" -description = "Python client for the IonQ Cloud Platform API" +description = "A client library for accessing IonQ Cloud Platform API" license = "Apache-2.0" license-files = ["LICENSE"] readme = "README.md" diff --git a/tests/conftest.py b/tests/conftest.py index 93c351e..da1dae8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from ionq_core import AuthenticatedClient, Client +BASE_URL = "https://test.invalid/v0.4" + def make_job_json(job_id, status="completed", **overrides): """Minimal valid job dict usable as both BaseJob and GetJobResponse.""" @@ -35,13 +37,13 @@ def make_job_json(job_id, status="completed", **overrides): @pytest.fixture def client() -> Client: - return Client(base_url="https://test.invalid/v0.4") + return Client(base_url=BASE_URL) @pytest.fixture def auth_client() -> AuthenticatedClient: return AuthenticatedClient( - base_url="https://test.invalid/v0.4", + base_url=BASE_URL, token="test-api-key", prefix="apiKey", auth_header_name="Authorization", diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index c507b2d..8673e87 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,13 +1,27 @@ -"""Pin README copy to runtime constants so doc drift is caught in CI.""" +"""Pin docs and config against runtime constants and each other to catch drift in CI.""" +import re +import tomllib from pathlib import Path -from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES -from ionq_core.ionq_client import _DEFAULT_TIMEOUT -from ionq_core.polling import _DEFAULT_INTERVAL, _MAX_INTERVAL +from ionq_core import exceptions, extensions +from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES, build_transport +from ionq_core.ionq_client import _AUTH_PREFIX, _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT +from ionq_core.polling import _BACKOFF_FACTOR, _DEFAULT_INTERVAL, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT -README = (Path(__file__).parent.parent / "README.md").read_text() +ROOT = Path(__file__).parent.parent +README = (ROOT / "README.md").read_text() +PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) +GITATTRIBUTES = (ROOT / ".gitattributes").read_text() + + +def _normalize(path: str) -> str: + """Strip trailing /* or ** so 'ionq_core/api/*' == 'ionq_core/api/**' == 'ionq_core/api'.""" + path = path.rstrip("/") + while path.endswith(("/*", "**")): + path = path[:-2].rstrip("/") + return path class TestREADMEMentionsCurrentConstants: @@ -28,4 +42,118 @@ def test_polling_defaults(self): assert f"{int(_DEFAULT_INTERVAL)} second " in README assert f"{int(_MAX_INTERVAL)}-second cap" in README assert f"{int(_POLL_DEFAULT_TIMEOUT)} seconds" in README - assert "1.5x" in README + assert f"{_BACKOFF_FACTOR}x" in README + + def test_backoff_parameters(self): + retry = build_transport()._transport.retry + assert f"factor {retry.backoff_factor}" in README + assert f"jitter {retry.backoff_jitter}" in README + assert f"capped at {int(retry.max_backoff_wait)} seconds" in README + + def test_default_base_url(self): + assert _DEFAULT_BASE_URL in README + + def test_auth_prefix(self): + assert f"Authorization: {_AUTH_PREFIX} " in README + + def test_exception_classes_in_diagram(self): + # Every public exception class in the package must appear in the README hierarchy. + for name in exceptions.__all__: + assert name in README, f"{name} missing from README exception diagram" + + +class TestExtensionDocstringPinsStatusCodes: + def test_client_extension_lists_retryable_codes(self): + doc = extensions.ClientExtension.__doc__ or "" + for code in (429, 500, 502, 503): + assert str(code) in doc, f"{code} missing from ClientExtension docstring" + assert "520-529" in doc + + +class TestSDKExampleBackend: + """Every QPU backend example in user-facing copy should agree, so a backend + rename is a single edit instead of a scavenger hunt.""" + + EXPECTED = "qpu.aria-1" + PATTERN = re.compile(r'SessionManager\([^)]*?"(qpu\.[^"]+)"') + + def test_session_module_examples(self): + text = (ROOT / "ionq_core" / "session.py").read_text() + backends = set(self.PATTERN.findall(text)) + assert backends == {self.EXPECTED}, f"divergent backends in session.py: {backends}" + + def test_readme_examples(self): + backends = set(self.PATTERN.findall(README)) + assert backends == {self.EXPECTED}, f"divergent backends in README: {backends}" + + +class TestPythonFloorConsistency: + """The lowest Python tested in CI must equal pyproject's floor must equal .python-version.""" + + @staticmethod + def _floor() -> str: + m = re.match(r">=(\d+\.\d+)", PYPROJECT["project"]["requires-python"]) + assert m, f"unexpected requires-python: {PYPROJECT['project']['requires-python']!r}" + return m.group(1) + + def test_pyproject_floor_matches_ci_matrix(self): + ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) + assert m, "CI matrix not found in ci.yml" + versions = re.findall(r'"(\d+\.\d+)"', m.group(1)) + assert self._floor() == min(versions) + + def test_python_version_file_matches_floor(self): + assert (ROOT / ".python-version").read_text().strip() == self._floor() + + def test_ruff_target_version_matches_floor(self): + floor = self._floor() + target = PYPROJECT["tool"]["ruff"]["target-version"] + assert target == "py" + floor.replace(".", ""), f"ruff target-version {target!r} != floor {floor!r}" + + def test_ty_python_version_matches_floor(self): + assert PYPROJECT["tool"]["ty"]["environment"]["python-version"] == self._floor() + + +class TestPackageDescriptionIsCanonical: + """pyproject, __init__.py, and README must agree on the package description.""" + + EXPECTED = "A client library for accessing IonQ Cloud Platform API" + + def test_pyproject_description(self): + assert PYPROJECT["project"]["description"] == self.EXPECTED + + def test_init_module_docstring(self): + import ionq_core + + assert (ionq_core.__doc__ or "").strip() == self.EXPECTED + + def test_readme_tagline(self): + assert self.EXPECTED in README + + def test_template_pins_description(self): + # The Jinja template must hardcode the canonical text; otherwise + # regen pulls package_description from the spec and silently drifts. + template = (ROOT / "custom-templates" / "package_init.py.jinja").read_text() + assert f'"{self.EXPECTED}"' in template + assert "package_description" not in template + + +class TestGeneratedPathsConsistency: + """ruff exclude, coverage omit, and .gitattributes must agree on which paths are generated.""" + + def test_ruff_excludes_match_coverage_omits(self): + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + coverage = {_normalize(p) for p in PYPROJECT["tool"]["coverage"]["run"]["omit"]} + assert ruff == coverage, f"ruff vs coverage divergence: {ruff ^ coverage}" + + def test_gitattributes_covers_ruff_paths_plus_init(self): + # __init__.py is generated (template-driven) but kept in scope for ruff/coverage + # because the template is hand-maintained. .gitattributes still marks it generated. + gitattr = { + _normalize(line.split()[0]) + for line in GITATTRIBUTES.splitlines() + if "linguist-generated=true" in line and line.startswith("ionq_core/") + } + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + assert gitattr == ruff | {"ionq_core/__init__.py"} diff --git a/tests/test_extensions.py b/tests/test_extensions.py index edcb3bd..b48e961 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10,8 +10,9 @@ AsyncEventHook, HookTransport, ) +from tests.conftest import BASE_URL -_BACKENDS_URL = "https://test.invalid/backends" +_BACKENDS_URL = f"{BASE_URL}/backends" class RecordingHook: diff --git a/tests/test_session.py b/tests/test_session.py index 8dd710d..e701baf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -5,8 +5,7 @@ from ionq_core.exceptions import IonQError from ionq_core.session import SessionManager - -_BASE = "https://test.invalid/v0.4" +from tests.conftest import BASE_URL as _BASE def _session_json(session_id="sess-1", status="created", active=True): diff --git a/tests/test_transport.py b/tests/test_transport.py index d35e490..c3aa02f 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -11,8 +11,9 @@ RateLimitError, ServerError, ) +from tests.conftest import BASE_URL -_URL = "https://test.invalid/backends" +_URL = f"{BASE_URL}/backends" class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): From 6739cc8fb81525827ca4e8ca6d06feddd6c3734c Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:59:16 -0700 Subject: [PATCH 12/22] Drop redundant template-level description pin The template's only output is ionq_core/__init__.py, which CI regenerates and then asserts against the canonical text via test_init_module_docstring. A separate template-content test was redundant - the output check catches the same drift. Also trim a parenthetical from the bug-reporting copy. --- CONTRIBUTING.md | 2 +- custom-templates/package_init.py.jinja | 3 +-- tests/test_docs_consistency.py | 7 ------- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 870f05a..4595fe7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, CUDA-Q ## Reporting bugs -Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.yml) and fill in every required field, including the version-info one-liner the template asks for. Incomplete reports may be closed. +Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.yml) and fill in every required field. Incomplete reports may be closed. ## Proposing changes diff --git a/custom-templates/package_init.py.jinja b/custom-templates/package_init.py.jinja index 8b1391d..d05c1c0 100644 --- a/custom-templates/package_init.py.jinja +++ b/custom-templates/package_init.py.jinja @@ -1,7 +1,6 @@ {% from "helpers.jinja" import safe_docstring %} {% set modules = ["exceptions", "extensions", "gates", "ionq_client", "pagination", "polling", "session"] %} -{# Pinned: keep this in sync with pyproject.toml and README.md (test_docs_consistency.py enforces). #} -{{ safe_docstring("A client library for accessing IonQ Cloud Platform API") }} +{{ safe_docstring(package_description) }} from . import {{ modules | join(", ") }} from .client import AuthenticatedClient, Client # noqa: F401 {% for m in modules %} diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 8673e87..924ff90 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -131,13 +131,6 @@ def test_init_module_docstring(self): def test_readme_tagline(self): assert self.EXPECTED in README - def test_template_pins_description(self): - # The Jinja template must hardcode the canonical text; otherwise - # regen pulls package_description from the spec and silently drifts. - template = (ROOT / "custom-templates" / "package_init.py.jinja").read_text() - assert f'"{self.EXPECTED}"' in template - assert "package_description" not in template - class TestGeneratedPathsConsistency: """ruff exclude, coverage omit, and .gitattributes must agree on which paths are generated.""" From 22cd9867cf9e5fa5c1b16b8d1dd8876ef6408d75 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:18:40 -0700 Subject: [PATCH 13/22] Pin remaining drift surfaces and decouple tests from /v0.4 Add eight new test classes to test_docs_consistency.py covering generator tool versions, ClientExtension docstring defaults, polling docstring constants, polling.__all__ in README, pyproject classifiers vs CI matrix, _DEFAULT_BASE_URL vs openapi.json servers[0].url, SPDX year consistency across hand-written and generated files, and resolution of cross-doc anchor links. Pin spec-drift.yml's curl URL to _DEFAULT_BASE_URL so a future v0.4 -> v0.5 bump can't silently keep curl'ing the stale endpoint. Derive the API path in tests/conftest.py and tests/test_session.py from _DEFAULT_BASE_URL via urlparse instead of hardcoding /v0.4. --- tests/conftest.py | 5 +- tests/test_docs_consistency.py | 141 ++++++++++++++++++++++++++++++++- tests/test_session.py | 9 ++- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index da1dae8..f58bd33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ +from urllib.parse import urlparse + import pytest from ionq_core import AuthenticatedClient, Client +from ionq_core.ionq_client import _DEFAULT_BASE_URL -BASE_URL = "https://test.invalid/v0.4" +BASE_URL = "https://test.invalid" + urlparse(_DEFAULT_BASE_URL).path def make_job_json(job_id, status="completed", **overrides): diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 924ff90..9b4deee 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,10 +1,12 @@ """Pin docs and config against runtime constants and each other to catch drift in CI.""" +import json import re import tomllib from pathlib import Path +from urllib.parse import urlparse -from ionq_core import exceptions, extensions +from ionq_core import exceptions, extensions, polling from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES, build_transport from ionq_core.ionq_client import _AUTH_PREFIX, _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT from ionq_core.polling import _BACKOFF_FACTOR, _DEFAULT_INTERVAL, _MAX_INTERVAL @@ -150,3 +152,140 @@ def test_gitattributes_covers_ruff_paths_plus_init(self): } ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} assert gitattr == ruff | {"ionq_core/__init__.py"} + + +class TestGeneratorVersionPins: + """CONTRIBUTING.md and the generated.yml workflow must pin identical tool versions.""" + + CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() + GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() + SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() + + @staticmethod + def _pin(text: str, package: str) -> str: + m = re.search(rf"{re.escape(package)}==(\S+)", text) + assert m, f"{package} pin not found" + return m.group(1) + + def test_openapi_python_client_versions_match(self): + assert self._pin(self.CONTRIB, "openapi-python-client") == self._pin(self.GENERATED_WF, "openapi-python-client") + + def test_oas_patch_versions_match(self): + assert self._pin(self.CONTRIB, "oas-patch") == self._pin(self.GENERATED_WF, "oas-patch") + + def test_spec_path_matches_default_base_url(self): + # Pinning to _DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until + # CONTRIBUTING and spec-drift.yml are updated too. Otherwise the drift workflow + # would silently keep curl'ing the stale endpoint. + spec_path = f"{urlparse(_DEFAULT_BASE_URL).path}/api-docs" + assert spec_path in self.CONTRIB + assert spec_path in self.SPEC_DRIFT_WF + + +class TestExtensionDocstringPinsDefaults: + """ClientExtension docstring numeric defaults must track the runtime constants.""" + + def test_max_retries_default_pinned(self): + doc = extensions.ClientExtension.__doc__ or "" + assert f"default of {DEFAULT_MAX_RETRIES}" in doc + + def test_timeout_default_pinned(self): + doc = extensions.ClientExtension.__doc__ or "" + assert f"default of {int(_DEFAULT_TIMEOUT.read)} seconds" in doc + + +class TestPollingDocstringPinsConstants: + """wait_for_job / async_wait_for_job docstrings must reference module constants.""" + + def test_sync_pins_backoff_factor(self): + assert f"{_BACKOFF_FACTOR}x" in (polling.wait_for_job.__doc__ or "") + + def test_sync_pins_max_interval(self): + assert f"{int(_MAX_INTERVAL)} seconds" in (polling.wait_for_job.__doc__ or "") + + def test_sync_pins_default_timeout(self): + assert f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}" in (polling.wait_for_job.__doc__ or "") + + def test_async_pins_default_timeout(self): + assert f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}" in (polling.async_wait_for_job.__doc__ or "") + + +class TestPollingPublicNamesInReadme: + """Every name in polling.__all__ must appear in the README.""" + + def test_each_name_present(self): + for name in polling.__all__: + assert name in README, f"{name} (from polling.__all__) missing from README" + + +class TestClassifiersMatchCIMatrix: + """pyproject Python classifiers must enumerate exactly the CI Python matrix.""" + + def test_classifiers_match_matrix(self): + ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) + assert m, "CI matrix not found in ci.yml" + ci_versions = sorted(re.findall(r'"(\d+\.\d+)"', m.group(1))) + classifiers = sorted( + c.split("::")[-1].strip() + for c in PYPROJECT["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") + ) + assert ci_versions == classifiers, f"matrix={ci_versions} classifiers={classifiers}" + + +class TestDefaultBaseURLMatchesSpec: + """_DEFAULT_BASE_URL must match the OpenAPI spec's primary server URL.""" + + def test_base_url_matches_spec_servers(self): + spec = json.loads((ROOT / "openapi.json").read_text()) + assert spec["servers"][0]["url"] == _DEFAULT_BASE_URL + + +class TestSPDXYearConsistency: + """All SPDX-FileCopyrightText years across ionq_core/ must agree. + + Generated files get the year injected by the openapi-python-client post-hook; + hand-written files have a static year. After a new-year regen, both sets + must be bumped together. + """ + + def test_single_year_across_package(self): + years = set() + for py in (ROOT / "ionq_core").rglob("*.py"): + m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) + if m: + years.add(m.group(1)) + assert len(years) <= 1, f"divergent SPDX years: {years}" + + +class TestDocAnchorsResolve: + """Cross-doc anchor links must resolve to actual headings. + + Add a (target_md, anchor) pair when introducing a new link from + issue/PR templates, README, or CONTRIBUTING that uses an in-page anchor. + """ + + REFS = [ + ("README.md", "looking-for-a-higher-level-interface"), + ("README.md", "versioning"), + ("CONTRIBUTING.md", "code-structure"), + ("CONTRIBUTING.md", "reporting-bugs"), + ("CONTRIBUTING.md", "proposing-changes"), + ] + + @staticmethod + def _slugify(s: str) -> str: + s = s.lower().strip() + s = re.sub(r"[^\w\s-]", "", s) + return re.sub(r"\s+", "-", s) + + @classmethod + def _headings(cls, path: Path) -> set[str]: + text = path.read_text() + return {cls._slugify(m.group(1)) for m in re.finditer(r"^#+\s+(.+?)\s*$", text, flags=re.MULTILINE)} + + def test_all_anchors_resolve(self): + for target, anchor in self.REFS: + headings = self._headings(ROOT / target) + assert anchor in headings, f"#{anchor} not found in {target}; have {sorted(headings)}" diff --git a/tests/test_session.py b/tests/test_session.py index e701baf..2d0da94 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,4 +1,5 @@ import json +from urllib.parse import urlparse import httpx import pytest @@ -7,6 +8,8 @@ from ionq_core.session import SessionManager from tests.conftest import BASE_URL as _BASE +_API_PATH = urlparse(_BASE).path + def _session_json(session_id="sess-1", status="created", active=True): return { @@ -36,7 +39,7 @@ def test_creates_and_ends_session(self, httpx_mock, auth_client): assert mgr.session_id == "sess-1" reqs = httpx_mock.get_requests() - assert reqs[0].method == "POST" and reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].method == "POST" and reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) def test_end_called_on_exception(self, httpx_mock, auth_client): @@ -98,7 +101,7 @@ def test_open_close_outside_context(self, httpx_mock, auth_client): mgr.close() reqs = httpx_mock.get_requests() - assert reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) def test_open_when_already_open_raises(self, httpx_mock, auth_client): @@ -137,7 +140,7 @@ async def test_creates_and_ends_session(self, httpx_mock, auth_client): assert mgr.session_id == "sess-1" reqs = httpx_mock.get_requests() - assert reqs[0].method == "POST" and reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].method == "POST" and reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) async def test_end_called_on_exception(self, httpx_mock, auth_client): From de102db57ab42e00fe07eabf76408a5d6b6acc1f Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:42:35 -0700 Subject: [PATCH 14/22] Simplify doc-drift tests and tighten surrounding copy Flatten tests/test_docs_consistency.py from 13 classes to module-level parametrized tests; same coverage in 217 lines instead of 291. Drop the markdown-anchor resolver test (GitHub already renders broken anchors visibly) and the backoff_factor pin (numbers live in _transport.py and won't drift independently of the README). Merge README's "SDK version vs API spec version" into "Versioning" - the SemVer carve-outs already imply API/SDK independence. Drop the duplicated higher-level-interface paragraph from CONTRIBUTING (already linked in "Getting help"). Tighten the post-generation hook paragraph to a one-liner; the config file is right above. Collapse the three-line max_retries fallback ladder in IonQClient into a single next() expression. --- CONTRIBUTING.md | 12 +- README.md | 6 +- ionq_core/ionq_client.py | 3 +- tests/test_docs_consistency.py | 354 +++++++++++++-------------------- 4 files changed, 145 insertions(+), 230 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4595fe7..1592ccd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,6 @@ This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unac - **Account, billing, or platform questions** -> . - **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a public issue. -If you are looking for a higher-level interface (Qiskit, Cirq, PennyLane, CUDA-Q, qBraid), see the framework SDKs linked from the [README](README.md#looking-for-a-higher-level-interface). `ionq-core` is the low-level HTTP client those SDKs are built on. - ## Reporting bugs Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.yml) and fill in every required field. Incomplete reports may be closed. @@ -30,15 +28,13 @@ For non-trivial changes, open an issue first to confirm scope before investing s ## Code structure -`ionq_core/` mixes machine-generated and hand-written code. Files that are overwritten on every regeneration carry the `# @generated` marker in their header; never edit them directly: +`ionq_core/` mixes machine-generated and hand-written code. Files carrying the `# @generated` marker are overwritten on every regeneration; never edit them directly: - `ionq_core/api/**` and `ionq_core/models/**` - `ionq_core/client.py`, `errors.py`, `types.py` - `ionq_core/__init__.py` (regenerated from `custom-templates/package_init.py.jinja`) -Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, is hand-written and accepts pull requests. `openapi.json` is the vendored upstream spec and is refreshed by the regeneration command below. - -The PR-time [`generated`](.github/workflows/generated.yml) workflow regenerates the client and fails the build if the result differs from what is committed. The mechanically generated paths (api/, models/, client.py, errors.py, types.py) are excluded from `ruff` lint and coverage measurement in `pyproject.toml`; `ty` runs against the whole package but with `invalid-argument-type` loosened for `api/` and `models/`, where it triggers on spec-driven `attrs` models. `__init__.py` stays in scope for all three because its template is ours to maintain. +Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, is hand-written. The [`generated`](.github/workflows/generated.yml) workflow regenerates on every PR and fails if the output differs from what is committed; tool-specific exclusions for generated paths live in `pyproject.toml` and `.gitattributes`. ## Development setup @@ -97,9 +93,7 @@ uvx openapi-python-client==0.28.3 generate \ --overwrite ``` -Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. - -Post-generation hooks defined in `openapi-python-client-config.yaml` patch `AuthenticatedClient.token` so API keys do not leak into `repr`, prepend the SPDX and `# @generated` header to every Python file, and run `ruff check --fix-only` followed by `ruff format`. +Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format. Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream. diff --git a/README.md b/README.md index 3101817..13b8a43 100644 --- a/README.md +++ b/README.md @@ -254,13 +254,9 @@ gpi_matrix(0.0) # Pauli X ms_matrix(0.0, 0.0) # maximally-entangling Molmer-Sorensen gate ``` -## SDK version vs API spec version - -The SDK version follows its own [SemVer 2.0](https://semver.org/spec/v2.0.0.html) cadence, independent of the upstream REST API version. To pin against a different API, pass an explicit `base_url` to `IonQClient`. - ## Versioning -This package follows [SemVer](https://semver.org/spec/v2.0.0.html), with three carve-outs that may ship in minor releases: +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. Three carve-outs may ship in minor releases: 1. Changes that affect static types only, without changing runtime behavior. 2. Changes to library internals that are technically importable but not documented for external use (anything beginning with an underscore, or absent from the API reference). diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 0daf56d..cfe50ac 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -131,8 +131,7 @@ def IonQClient( ] user_agent = " ".join(ua_parts) effective_timeout = timeout or ext.timeout or _DEFAULT_TIMEOUT - ext_retries = ext.max_retries if ext.max_retries is not None else DEFAULT_MAX_RETRIES - effective_retries = max_retries if max_retries is not None else ext_retries + effective_retries = next(v for v in (max_retries, ext.max_retries, DEFAULT_MAX_RETRIES) if v is not None) headers = {**ext.default_headers, "User-Agent": user_agent} diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 9b4deee..b584a40 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -6,8 +6,10 @@ from pathlib import Path from urllib.parse import urlparse +import pytest + from ionq_core import exceptions, extensions, polling -from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES, build_transport +from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES from ionq_core.ionq_client import _AUTH_PREFIX, _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT from ionq_core.polling import _BACKOFF_FACTOR, _DEFAULT_INTERVAL, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT @@ -16,6 +18,14 @@ README = (ROOT / "README.md").read_text() PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) GITATTRIBUTES = (ROOT / ".gitattributes").read_text() +CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() +GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() +SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() +SESSION_PY = (ROOT / "ionq_core" / "session.py").read_text() + +PACKAGE_DESCRIPTION = "A client library for accessing IonQ Cloud Platform API" +EXAMPLE_BACKEND = "qpu.aria-1" +_BACKEND_PATTERN = re.compile(r'SessionManager\([^)]*?"(qpu\.[^"]+)"') def _normalize(path: str) -> str: @@ -26,266 +36,182 @@ def _normalize(path: str) -> str: return path -class TestREADMEMentionsCurrentConstants: - def test_retry_status_codes_mentioned(self): - for code in (429, 500, 502, 503): - assert str(code) in README - assert "520" in README and "529" in README - assert frozenset({429, 500, 502, 503, *range(520, 530)}) == RETRYABLE_STATUS_CODES - - def test_default_max_retries(self): - assert f"{DEFAULT_MAX_RETRIES} retries" in README - - def test_default_timeout(self): - assert f"{int(_DEFAULT_TIMEOUT.read)} seconds" in README - assert f"{int(_DEFAULT_TIMEOUT.connect)}-second connect" in README - - def test_polling_defaults(self): - assert f"{int(_DEFAULT_INTERVAL)} second " in README - assert f"{int(_MAX_INTERVAL)}-second cap" in README - assert f"{int(_POLL_DEFAULT_TIMEOUT)} seconds" in README - assert f"{_BACKOFF_FACTOR}x" in README - - def test_backoff_parameters(self): - retry = build_transport()._transport.retry - assert f"factor {retry.backoff_factor}" in README - assert f"jitter {retry.backoff_jitter}" in README - assert f"capped at {int(retry.max_backoff_wait)} seconds" in README - - def test_default_base_url(self): - assert _DEFAULT_BASE_URL in README - - def test_auth_prefix(self): - assert f"Authorization: {_AUTH_PREFIX} " in README - - def test_exception_classes_in_diagram(self): - # Every public exception class in the package must appear in the README hierarchy. - for name in exceptions.__all__: - assert name in README, f"{name} missing from README exception diagram" - +def _python_floor() -> str: + m = re.match(r">=(\d+\.\d+)", PYPROJECT["project"]["requires-python"]) + assert m, f"unexpected requires-python: {PYPROJECT['project']['requires-python']!r}" + return m.group(1) -class TestExtensionDocstringPinsStatusCodes: - def test_client_extension_lists_retryable_codes(self): - doc = extensions.ClientExtension.__doc__ or "" - for code in (429, 500, 502, 503): - assert str(code) in doc, f"{code} missing from ClientExtension docstring" - assert "520-529" in doc +def _ci_python_versions() -> list[str]: + ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) + assert m, "CI matrix not found in ci.yml" + return re.findall(r'"(\d+\.\d+)"', m.group(1)) -class TestSDKExampleBackend: - """Every QPU backend example in user-facing copy should agree, so a backend - rename is a single edit instead of a scavenger hunt.""" - EXPECTED = "qpu.aria-1" - PATTERN = re.compile(r'SessionManager\([^)]*?"(qpu\.[^"]+)"') +def _pin(text: str, package: str) -> str: + m = re.search(rf"{re.escape(package)}==(\S+)", text) + assert m, f"{package} pin not found" + return m.group(1) - def test_session_module_examples(self): - text = (ROOT / "ionq_core" / "session.py").read_text() - backends = set(self.PATTERN.findall(text)) - assert backends == {self.EXPECTED}, f"divergent backends in session.py: {backends}" - def test_readme_examples(self): - backends = set(self.PATTERN.findall(README)) - assert backends == {self.EXPECTED}, f"divergent backends in README: {backends}" +@pytest.mark.parametrize( + "needle", + [ + *(str(c) for c in (429, 500, 502, 503)), + "520", + "529", + f"{DEFAULT_MAX_RETRIES} retries", + f"{int(_DEFAULT_TIMEOUT.read)} seconds", + f"{int(_DEFAULT_TIMEOUT.connect)}-second connect", + f"{int(_DEFAULT_INTERVAL)} second ", + f"{int(_MAX_INTERVAL)}-second cap", + f"{int(_POLL_DEFAULT_TIMEOUT)} seconds", + f"{_BACKOFF_FACTOR}x", + _DEFAULT_BASE_URL, + f"Authorization: {_AUTH_PREFIX} ", + ], +) +def test_readme_mentions_runtime_constant(needle): + assert needle in README -class TestPythonFloorConsistency: - """The lowest Python tested in CI must equal pyproject's floor must equal .python-version.""" +def test_retryable_status_codes_match_runtime(): + assert frozenset({429, 500, 502, 503, *range(520, 530)}) == RETRYABLE_STATUS_CODES - @staticmethod - def _floor() -> str: - m = re.match(r">=(\d+\.\d+)", PYPROJECT["project"]["requires-python"]) - assert m, f"unexpected requires-python: {PYPROJECT['project']['requires-python']!r}" - return m.group(1) - def test_pyproject_floor_matches_ci_matrix(self): - ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() - m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) - assert m, "CI matrix not found in ci.yml" - versions = re.findall(r'"(\d+\.\d+)"', m.group(1)) - assert self._floor() == min(versions) +@pytest.mark.parametrize("name", exceptions.__all__) +def test_readme_lists_exception_class(name): + assert name in README, f"{name} missing from README exception diagram" - def test_python_version_file_matches_floor(self): - assert (ROOT / ".python-version").read_text().strip() == self._floor() - def test_ruff_target_version_matches_floor(self): - floor = self._floor() - target = PYPROJECT["tool"]["ruff"]["target-version"] - assert target == "py" + floor.replace(".", ""), f"ruff target-version {target!r} != floor {floor!r}" +@pytest.mark.parametrize("name", polling.__all__) +def test_readme_lists_polling_name(name): + assert name in README, f"{name} (from polling.__all__) missing from README" - def test_ty_python_version_matches_floor(self): - assert PYPROJECT["tool"]["ty"]["environment"]["python-version"] == self._floor() +@pytest.mark.parametrize( + "needle", + [ + *(str(c) for c in (429, 500, 502, 503)), + "520-529", + f"default of {DEFAULT_MAX_RETRIES}", + f"default of {int(_DEFAULT_TIMEOUT.read)} seconds", + ], +) +def test_client_extension_docstring_pins(needle): + doc = extensions.ClientExtension.__doc__ or "" + assert needle in doc, f"{needle!r} missing from ClientExtension docstring" -class TestPackageDescriptionIsCanonical: - """pyproject, __init__.py, and README must agree on the package description.""" - EXPECTED = "A client library for accessing IonQ Cloud Platform API" +@pytest.mark.parametrize( + "fn,needle", + [ + (polling.wait_for_job, f"{_BACKOFF_FACTOR}x"), + (polling.wait_for_job, f"{int(_MAX_INTERVAL)} seconds"), + (polling.wait_for_job, f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}"), + (polling.async_wait_for_job, f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}"), + ], +) +def test_polling_docstring_pins(fn, needle): + assert needle in (fn.__doc__ or ""), f"{needle!r} missing from {fn.__name__}" - def test_pyproject_description(self): - assert PYPROJECT["project"]["description"] == self.EXPECTED - def test_init_module_docstring(self): - import ionq_core +@pytest.mark.parametrize("name,text", [("session.py", SESSION_PY), ("README.md", README)]) +def test_session_example_backend_consistent(name, text): + backends = set(_BACKEND_PATTERN.findall(text)) + assert backends == {EXAMPLE_BACKEND}, f"divergent backends in {name}: {backends}" - assert (ionq_core.__doc__ or "").strip() == self.EXPECTED - def test_readme_tagline(self): - assert self.EXPECTED in README +def test_pyproject_floor_matches_ci_matrix(): + assert _python_floor() == min(_ci_python_versions()) -class TestGeneratedPathsConsistency: - """ruff exclude, coverage omit, and .gitattributes must agree on which paths are generated.""" +def test_python_version_file_matches_floor(): + assert (ROOT / ".python-version").read_text().strip() == _python_floor() - def test_ruff_excludes_match_coverage_omits(self): - ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} - coverage = {_normalize(p) for p in PYPROJECT["tool"]["coverage"]["run"]["omit"]} - assert ruff == coverage, f"ruff vs coverage divergence: {ruff ^ coverage}" - def test_gitattributes_covers_ruff_paths_plus_init(self): - # __init__.py is generated (template-driven) but kept in scope for ruff/coverage - # because the template is hand-maintained. .gitattributes still marks it generated. - gitattr = { - _normalize(line.split()[0]) - for line in GITATTRIBUTES.splitlines() - if "linguist-generated=true" in line and line.startswith("ionq_core/") - } - ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} - assert gitattr == ruff | {"ionq_core/__init__.py"} +def test_ruff_target_version_matches_floor(): + floor = _python_floor() + target = PYPROJECT["tool"]["ruff"]["target-version"] + assert target == "py" + floor.replace(".", ""), f"ruff target-version {target!r} != floor {floor!r}" -class TestGeneratorVersionPins: - """CONTRIBUTING.md and the generated.yml workflow must pin identical tool versions.""" +def test_ty_python_version_matches_floor(): + assert PYPROJECT["tool"]["ty"]["environment"]["python-version"] == _python_floor() - CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() - GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() - SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() - @staticmethod - def _pin(text: str, package: str) -> str: - m = re.search(rf"{re.escape(package)}==(\S+)", text) - assert m, f"{package} pin not found" - return m.group(1) +def test_classifiers_match_ci_matrix(): + classifiers = sorted( + c.split("::")[-1].strip() + for c in PYPROJECT["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") + ) + assert sorted(_ci_python_versions()) == classifiers, f"matrix={_ci_python_versions()} classifiers={classifiers}" - def test_openapi_python_client_versions_match(self): - assert self._pin(self.CONTRIB, "openapi-python-client") == self._pin(self.GENERATED_WF, "openapi-python-client") - def test_oas_patch_versions_match(self): - assert self._pin(self.CONTRIB, "oas-patch") == self._pin(self.GENERATED_WF, "oas-patch") +def test_pyproject_description_canonical(): + assert PYPROJECT["project"]["description"] == PACKAGE_DESCRIPTION - def test_spec_path_matches_default_base_url(self): - # Pinning to _DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until - # CONTRIBUTING and spec-drift.yml are updated too. Otherwise the drift workflow - # would silently keep curl'ing the stale endpoint. - spec_path = f"{urlparse(_DEFAULT_BASE_URL).path}/api-docs" - assert spec_path in self.CONTRIB - assert spec_path in self.SPEC_DRIFT_WF +def test_init_module_docstring_canonical(): + import ionq_core -class TestExtensionDocstringPinsDefaults: - """ClientExtension docstring numeric defaults must track the runtime constants.""" + assert (ionq_core.__doc__ or "").strip() == PACKAGE_DESCRIPTION - def test_max_retries_default_pinned(self): - doc = extensions.ClientExtension.__doc__ or "" - assert f"default of {DEFAULT_MAX_RETRIES}" in doc - def test_timeout_default_pinned(self): - doc = extensions.ClientExtension.__doc__ or "" - assert f"default of {int(_DEFAULT_TIMEOUT.read)} seconds" in doc +def test_readme_tagline_canonical(): + assert PACKAGE_DESCRIPTION in README -class TestPollingDocstringPinsConstants: - """wait_for_job / async_wait_for_job docstrings must reference module constants.""" +def test_ruff_excludes_match_coverage_omits(): + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + coverage = {_normalize(p) for p in PYPROJECT["tool"]["coverage"]["run"]["omit"]} + assert ruff == coverage, f"ruff vs coverage divergence: {ruff ^ coverage}" - def test_sync_pins_backoff_factor(self): - assert f"{_BACKOFF_FACTOR}x" in (polling.wait_for_job.__doc__ or "") - def test_sync_pins_max_interval(self): - assert f"{int(_MAX_INTERVAL)} seconds" in (polling.wait_for_job.__doc__ or "") +def test_gitattributes_covers_ruff_paths_plus_init(): + # __init__.py is generated (template-driven) but kept in scope for ruff/coverage + # because the template is hand-maintained. .gitattributes still marks it generated. + gitattr = { + _normalize(line.split()[0]) + for line in GITATTRIBUTES.splitlines() + if "linguist-generated=true" in line and line.startswith("ionq_core/") + } + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + assert gitattr == ruff | {"ionq_core/__init__.py"} - def test_sync_pins_default_timeout(self): - assert f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}" in (polling.wait_for_job.__doc__ or "") - def test_async_pins_default_timeout(self): - assert f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}" in (polling.async_wait_for_job.__doc__ or "") +def test_openapi_python_client_versions_match(): + assert _pin(CONTRIB, "openapi-python-client") == _pin(GENERATED_WF, "openapi-python-client") -class TestPollingPublicNamesInReadme: - """Every name in polling.__all__ must appear in the README.""" +def test_oas_patch_versions_match(): + assert _pin(CONTRIB, "oas-patch") == _pin(GENERATED_WF, "oas-patch") - def test_each_name_present(self): - for name in polling.__all__: - assert name in README, f"{name} (from polling.__all__) missing from README" +def test_spec_path_matches_default_base_url(): + # Pinning to _DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until + # CONTRIBUTING and spec-drift.yml are updated too. Otherwise the drift workflow + # would silently keep curl'ing the stale endpoint. + spec_path = f"{urlparse(_DEFAULT_BASE_URL).path}/api-docs" + assert spec_path in CONTRIB + assert spec_path in SPEC_DRIFT_WF -class TestClassifiersMatchCIMatrix: - """pyproject Python classifiers must enumerate exactly the CI Python matrix.""" - def test_classifiers_match_matrix(self): - ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() - m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) - assert m, "CI matrix not found in ci.yml" - ci_versions = sorted(re.findall(r'"(\d+\.\d+)"', m.group(1))) - classifiers = sorted( - c.split("::")[-1].strip() - for c in PYPROJECT["project"]["classifiers"] - if c.startswith("Programming Language :: Python :: 3.") - ) - assert ci_versions == classifiers, f"matrix={ci_versions} classifiers={classifiers}" +def test_default_base_url_matches_spec_servers(): + spec = json.loads((ROOT / "openapi.json").read_text()) + assert spec["servers"][0]["url"] == _DEFAULT_BASE_URL -class TestDefaultBaseURLMatchesSpec: - """_DEFAULT_BASE_URL must match the OpenAPI spec's primary server URL.""" - - def test_base_url_matches_spec_servers(self): - spec = json.loads((ROOT / "openapi.json").read_text()) - assert spec["servers"][0]["url"] == _DEFAULT_BASE_URL - - -class TestSPDXYearConsistency: - """All SPDX-FileCopyrightText years across ionq_core/ must agree. - - Generated files get the year injected by the openapi-python-client post-hook; - hand-written files have a static year. After a new-year regen, both sets - must be bumped together. - """ - - def test_single_year_across_package(self): - years = set() - for py in (ROOT / "ionq_core").rglob("*.py"): - m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) - if m: - years.add(m.group(1)) - assert len(years) <= 1, f"divergent SPDX years: {years}" - - -class TestDocAnchorsResolve: - """Cross-doc anchor links must resolve to actual headings. - - Add a (target_md, anchor) pair when introducing a new link from - issue/PR templates, README, or CONTRIBUTING that uses an in-page anchor. +def test_single_spdx_year_across_package(): + """Generated files get the year injected by the openapi-python-client post-hook; + hand-written files have a static year. After a new-year regen, both sets must + be bumped together. """ - - REFS = [ - ("README.md", "looking-for-a-higher-level-interface"), - ("README.md", "versioning"), - ("CONTRIBUTING.md", "code-structure"), - ("CONTRIBUTING.md", "reporting-bugs"), - ("CONTRIBUTING.md", "proposing-changes"), - ] - - @staticmethod - def _slugify(s: str) -> str: - s = s.lower().strip() - s = re.sub(r"[^\w\s-]", "", s) - return re.sub(r"\s+", "-", s) - - @classmethod - def _headings(cls, path: Path) -> set[str]: - text = path.read_text() - return {cls._slugify(m.group(1)) for m in re.finditer(r"^#+\s+(.+?)\s*$", text, flags=re.MULTILINE)} - - def test_all_anchors_resolve(self): - for target, anchor in self.REFS: - headings = self._headings(ROOT / target) - assert anchor in headings, f"#{anchor} not found in {target}; have {sorted(headings)}" + years = set() + for py in (ROOT / "ionq_core").rglob("*.py"): + m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) + if m: + years.add(m.group(1)) + assert len(years) == 1, f"expected exactly one SPDX year, found: {years}" From 3ec148d8f9e4ab151df96faf181c94dec161ba02 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:56:41 -0700 Subject: [PATCH 15/22] Cut documentation duplication and drift surfaces Trim README, CONTRIBUTING, and SECURITY to single-source content; drop the README pin tests for the removed copy. Module docstrings (rendered into the published API reference) now own behavioral detail; the README is the entry point only. - README: drop Authentication detail, Async usage, exception ASCII tree, retry/timeout values, full pagination/polling/sessions snippets, the Advanced section, and versioning carve-outs - all duplicated in module docstrings or drift-prone constants. - CONTRIBUTING: drop the hand-maintained generated-file list (now .gitattributes is the single source), the Versioning section (duplicate of README), and the Releasing section (maintainer-only). - SECURITY: drop the Supported Versions table - it would lie the moment 0.2 ships; the prose policy above already covers it. - test_docs_consistency: drop test_readme_mentions_runtime_constant, test_readme_lists_exception_class, test_readme_lists_polling_name, and the README half of the session-backend pin. Internal-consistency checks (Python floor, classifiers, ruff/coverage/.gitattributes paths, generated-tool versions, base URL, SPDX year, ClientExtension and polling docstring pins) are unchanged. --- CONTRIBUTING.md | 32 +---- README.md | 210 ++------------------------------- SECURITY.md | 5 - tests/test_docs_consistency.py | 44 +------ 4 files changed, 15 insertions(+), 276 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1592ccd..ba9ebc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,30 +12,16 @@ This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unac - **Account, billing, or platform questions** -> . - **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a public issue. -## Reporting bugs - -Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.yml) and fill in every required field. Incomplete reports may be closed. - ## Proposing changes `ionq-core` is generated from IonQ's OpenAPI specification, and most of the package is overwritten on every regeneration. Before opening a pull request, check where your change belongs: - **API surface changes** (new endpoints, parameter names, response shapes) -> these originate in the upstream OpenAPI spec, not this repo. Open an issue describing the change you want to see. -- **Bugs in generated code** (any file with the `# @generated` marker) -> these originate upstream or in the generator config. File an issue rather than editing the generated output. See [Code structure](#code-structure). +- **Bugs in generated code** -> files marked `linguist-generated=true` in [`.gitattributes`](.gitattributes) are overwritten on every regeneration; never edit them directly. File an issue rather than editing the generated output. - **Hand-written extensions, tests, docs, type hints, tooling** -> pull requests welcome. For non-trivial changes, open an issue first to confirm scope before investing significant time. -## Code structure - -`ionq_core/` mixes machine-generated and hand-written code. Files carrying the `# @generated` marker are overwritten on every regeneration; never edit them directly: - -- `ionq_core/api/**` and `ionq_core/models/**` -- `ionq_core/client.py`, `errors.py`, `types.py` -- `ionq_core/__init__.py` (regenerated from `custom-templates/package_init.py.jinja`) - -Everything else under `ionq_core/`, plus `tests/` and `custom-templates/`, is hand-written. The [`generated`](.github/workflows/generated.yml) workflow regenerates on every PR and fails if the output differs from what is committed; tool-specific exclusions for generated paths live in `pyproject.toml` and `.gitattributes`. - ## Development setup This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency management; the `uv.lock` file is canonical and CI runs with `UV_FROZEN=true`. @@ -47,7 +33,7 @@ uv sync pre-commit install ``` -Python 3.12 or newer is required; the CI matrix is the source of truth for tested interpreters. +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 @@ -109,20 +95,6 @@ There is no enforced commit-message format, but PR titles become release notes v User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) under the next release section, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## Versioning - -This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) with three carve-outs documented in the [README](README.md#versioning). The SDK version is independent of the IonQ API version. - -## Releasing - -Maintainers only: - -1. Bump `version` in `pyproject.toml`. -2. Add a dated section to `CHANGELOG.md`. -3. Tag the commit `vX.Y.Z` on `main` and push the tag. - -The [`release`](.github/workflows/release.yml) workflow verifies that the tag matches `pyproject.toml` and that the version is not already on PyPI, builds with `hatchling`, publishes via PyPI Trusted Publishing (OIDC, no API token), and creates a GitHub Release with auto-generated notes. - ## Contributor License Agreement Contributions are accepted under the project's [Apache 2.0 license](LICENSE). To receive IonQ's CLA, email . diff --git a/README.md b/README.md index 13b8a43..a13559a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ A client library for accessing IonQ Cloud Platform API. [![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-ionq.github.io-blue.svg)](https://ionq.github.io/ionq-core-python/) -`ionq-core` is a typed, async-capable Python client for the [IonQ Cloud Platform](https://ionq.com) REST API. It covers job submission and lifecycle, results retrieval, backend characterizations, sessions, and usage reporting. The HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); a small set of hand-written extensions wraps it with retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. +`ionq-core` is a typed, async-capable Python client for the [IonQ Cloud Platform](https://ionq.com) REST API. The HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); a small set of hand-written extensions wraps it with retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. -The full API reference for this package is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). +The full API reference is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). ## Looking for a higher-level interface? @@ -19,7 +19,7 @@ The full API reference for this package is published at [ionq.github.io/ionq-cor - **Qiskit** users -> [`qiskit-ionq`](https://pypi.org/project/qiskit-ionq/) - **Cirq** users -> [`cirq-ionq`](https://pypi.org/project/cirq-ionq/) - **PennyLane** users -> [`pennylane-ionq`](https://pypi.org/project/pennylane-ionq/) -- **CUDA-Q** users -> IonQ is configured as a backend in [NVIDIA CUDA-Q](https://github.com/NVIDIA/cuda-quantum/blob/main/runtime/cudaq/platform/default/rest/helpers/ionq/IonQServerHelper.cpp). +- **CUDA-Q** users -> IonQ is configured as a backend in [NVIDIA CUDA-Q](https://github.com/NVIDIA/cuda-quantum). - **Multi-vendor users** -> IonQ is reachable via [`qbraid`](https://pypi.org/project/qbraid/). Use this package directly if you want programmatic access to the IonQ REST API close to the wire, or if you are building a downstream SDK on top of it. @@ -30,8 +30,6 @@ Use this package directly if you want programmatic access to the IonQ REST API c pip install ionq-core ``` -Requires Python 3.12 or newer. - ## Quickstart Submit a Bell-state circuit on the cloud simulator and read the result probabilities: @@ -57,212 +55,18 @@ body = CircuitJobCreationPayload.from_dict({ }) job = create_job.sync(client=client, body=body) -completed = wait_for_job(client, job.id, timeout=120) +completed = wait_for_job(client, job.id) probs = get_job_probabilities.sync(uuid=job.id, client=client) 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. -## Authentication - -Authentication uses an API key passed as `Authorization: apiKey ` (note the `apiKey` prefix, not `Bearer`). `IonQClient` reads the key from the `IONQ_API_KEY` environment variable by default: - -```sh -export IONQ_API_KEY="your-api-key" -``` - -```python -from ionq_core import IonQClient - -client = IonQClient() # IONQ_API_KEY from env -client = IonQClient(api_key="your-key") # explicit -client = IonQClient(base_url="https://api.ionq.co/v0.4") # default base URL -``` - -If neither argument nor environment variable is set, `IonQClient()` raises `ValueError`. API keys are issued from your IonQ account. - -## Async usage - -Every endpoint exposes `asyncio` and `asyncio_detailed` callables alongside the synchronous variants. `IonQClient` itself supports both `with` and `async with`: - -```python -import asyncio -from ionq_core import IonQClient -from ionq_core.api.backends import get_backends - -async def main(): - async with IonQClient() as client: - backends = await get_backends.asyncio(client=client) - print([b.backend for b in backends]) - -asyncio.run(main()) -``` - -The client opens both sync and async httpx transports during construction, so the same `client` instance can be used from both code paths. - -## Handling errors - -All exceptions inherit from `IonQError`. Concrete subclasses map to HTTP statuses and transport failures: - -```text -IonQError -├── APIConnectionError # network / DNS / TLS failures -│ └── APITimeoutError # request timed out -└── APIError # 4xx / 5xx HTTP responses - ├── BadRequestError # 400 - ├── AuthenticationError # 401 - ├── PermissionDeniedError # 403 - ├── NotFoundError # 404 - ├── RateLimitError # 429 (carries retry_after) - └── ServerError # 5xx -``` - -```python -from ionq_core import AuthenticationError, RateLimitError -from ionq_core.api.default import create_job - -try: - job = create_job.sync(client=client, body=payload) -except AuthenticationError as e: - print(f"Invalid API key (request {e.request_id})") -except RateLimitError as e: - print(f"Rate limited; retry after {e.retry_after}s") -``` - -Every `APIError` carries `status_code`, `body` (parsed JSON or raw string), `message`, and `request_id` from the `x-request-id` response header. Include `request_id` when contacting IonQ support about a specific failure. - -## Retries and timeouts - -Transient failures are retried automatically. The default policy is 2 retries on `429`, `500`, `502`, `503`, and `520`-`529`, with exponential backoff (factor 0.5, jitter 0.5, capped at 60 seconds). `Retry-After` headers are honored. The default request timeout is 60 seconds with a 10-second connect timeout. - -```python -import httpx -from ionq_core import IonQClient - -client = IonQClient( - max_retries=5, - timeout=httpx.Timeout(30.0, connect=10.0), -) -``` - -Set `max_retries=0` to disable retries entirely. - -## Pagination - -List endpoints return cursor-paginated responses. `iter_jobs`, `aiter_jobs`, `iter_session_jobs`, and `aiter_session_jobs` follow the cursor automatically and yield individual job objects: - -```python -from itertools import islice -from ionq_core import iter_jobs - -for job in islice(iter_jobs(client, status="completed"), 100): - print(job.id, job.backend) -``` - -Each helper accepts the same filters as the underlying `get_jobs` / `get_session_jobs` endpoints (`status`, `target`, `session_id`, `submitter_id`, `limit`). - -## Polling for job completion - -`wait_for_job` polls a job until it reaches a terminal state (`completed`, `failed`, or `canceled`) or the timeout elapses. Polling starts at 1 second and grows by 1.5x to a 30-second cap; the default total timeout is 300 seconds. - -```python -from ionq_core import wait_for_job, JobTimeoutError, JobFailedError - -try: - job = wait_for_job(client, job_id, timeout=300) -except JobTimeoutError as e: - print(f"Polling timed out (last status: {e.last_status})") -except JobFailedError as e: - print(f"Job failed: {e.failure}") -``` - -Pass `raise_on_failure=False` to receive the failed-job object instead of an exception. The async equivalent is `async_wait_for_job`. - -## Sessions - -`SessionManager` owns a long-running IonQ QPU session, optionally with limits on jobs, time (in minutes), or cost (in USD): - -```python -from ionq_core import SessionManager - -with SessionManager(client, "qpu.aria-1", max_jobs=10, max_time=60) as session: - print(session.session_id) - print(session.status()) # "started" - # submit jobs against session.session_id ... -# the session is ended automatically on exit -``` - -`SessionManager.from_id(client, session_id)` reconnects to an existing session. The async path uses `async with` and `async_status()`. - -## Advanced - -### Logging and request hooks - -`ClientExtension` bundles hooks, headers, and transport overrides. The `EventHook` and `AsyncEventHook` protocols receive each request and response, and may opt into `on_error`: - -```python -import httpx -from ionq_core import IonQClient, ClientExtension, EventHook - -class LoggingHook(EventHook): - def on_request(self, request: httpx.Request) -> None: - print(f"--> {request.method} {request.url}") - - def on_response(self, request: httpx.Request, response: httpx.Response) -> None: - print(f"<-- {response.status_code} {request.url}") - -client = IonQClient(extension=ClientExtension(event_hooks=(LoggingHook(),))) -``` - -Hook exceptions are logged and suppressed by default. Set `debug_hooks=True` on `ClientExtension` to re-raise them. - -### Custom HTTP client - -For unusual deployments (proxies, custom CA bundles, mTLS), pass `httpx_args` through `IonQClient` or attach your own `httpx.Client` to the returned client: - -```python -import httpx - -custom = httpx.Client(verify="/path/to/ca-bundle.pem") -client.set_httpx_client(custom) -``` - -For programmatic transport composition (caching, tracing, request signing), set `ClientExtension.transport_wrapper` and `async_transport_wrapper` to wrap the default retry transport. - -### Mapping errors for downstream SDKs - -`ClientExtension.error_mapper` lets a downstream SDK translate raised exceptions without losing the original chain: - -```python -def map_error(exc: Exception) -> Exception: - if isinstance(exc, RateLimitError): - return MyDownstreamRateLimit(str(exc)) - return exc - -client = IonQClient(extension=ClientExtension(error_mapper=map_error)) -``` - -### Native trapped-ion gates - -`gpi_matrix`, `gpi2_matrix`, `ms_matrix`, and `zz_matrix` return unitary matrices for IonQ's native gates as plain Python nested tuples (no NumPy dependency). Phase parameters are in turns (fractions of 2*pi); interaction angles are in units of pi. - -```python -from ionq_core import gpi_matrix, ms_matrix - -gpi_matrix(0.0) # Pauli X -ms_matrix(0.0, 0.0) # maximally-entangling Molmer-Sorensen gate -``` +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/). ## 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. Three carve-outs may ship in minor releases: - -1. Changes that affect static types only, without changing runtime behavior. -2. Changes to library internals that are technically importable but not documented for external use (anything beginning with an underscore, or absent from the API reference). -3. Changes that we do not expect to impact the vast majority of users in practice. - -Print the installed version with: +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 @@ -273,7 +77,7 @@ The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core- ## Contributing -Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. See [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md) for the boundary between generated and hand-written code, development setup, the regeneration command, the 100% branch-coverage gate on hand-written code, and CLA details. +Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. See [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md) for the boundary between generated and hand-written code, development setup, and the regeneration command. ## Support diff --git a/SECURITY.md b/SECURITY.md index a5a45cb..4668486 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,11 +33,6 @@ If you are unsure whether a planned activity is consistent with this policy, con `ionq-core` is pre-1.0. While the package is in the `0.x` series, **only the latest released minor receives security fixes**. This policy will harden once `1.0` is released. -| Version | Supported | -| ----------------- | --------- | -| `0.1.x` (latest) | Yes | -| Older `0.x` | No | - ## Scope This policy covers the source code in this repository and the `ionq-core` distribution published to PyPI from it. diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index b584a40..6ac2ce3 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -8,10 +8,10 @@ import pytest -from ionq_core import exceptions, extensions, polling +from ionq_core import extensions, polling from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES -from ionq_core.ionq_client import _AUTH_PREFIX, _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT -from ionq_core.polling import _BACKOFF_FACTOR, _DEFAULT_INTERVAL, _MAX_INTERVAL +from ionq_core.ionq_client import _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT +from ionq_core.polling import _BACKOFF_FACTOR, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT ROOT = Path(__file__).parent.parent @@ -55,41 +55,10 @@ def _pin(text: str, package: str) -> str: return m.group(1) -@pytest.mark.parametrize( - "needle", - [ - *(str(c) for c in (429, 500, 502, 503)), - "520", - "529", - f"{DEFAULT_MAX_RETRIES} retries", - f"{int(_DEFAULT_TIMEOUT.read)} seconds", - f"{int(_DEFAULT_TIMEOUT.connect)}-second connect", - f"{int(_DEFAULT_INTERVAL)} second ", - f"{int(_MAX_INTERVAL)}-second cap", - f"{int(_POLL_DEFAULT_TIMEOUT)} seconds", - f"{_BACKOFF_FACTOR}x", - _DEFAULT_BASE_URL, - f"Authorization: {_AUTH_PREFIX} ", - ], -) -def test_readme_mentions_runtime_constant(needle): - assert needle in README - - def test_retryable_status_codes_match_runtime(): assert frozenset({429, 500, 502, 503, *range(520, 530)}) == RETRYABLE_STATUS_CODES -@pytest.mark.parametrize("name", exceptions.__all__) -def test_readme_lists_exception_class(name): - assert name in README, f"{name} missing from README exception diagram" - - -@pytest.mark.parametrize("name", polling.__all__) -def test_readme_lists_polling_name(name): - assert name in README, f"{name} (from polling.__all__) missing from README" - - @pytest.mark.parametrize( "needle", [ @@ -117,10 +86,9 @@ def test_polling_docstring_pins(fn, needle): assert needle in (fn.__doc__ or ""), f"{needle!r} missing from {fn.__name__}" -@pytest.mark.parametrize("name,text", [("session.py", SESSION_PY), ("README.md", README)]) -def test_session_example_backend_consistent(name, text): - backends = set(_BACKEND_PATTERN.findall(text)) - assert backends == {EXAMPLE_BACKEND}, f"divergent backends in {name}: {backends}" +def test_session_example_backend_consistent(): + backends = set(_BACKEND_PATTERN.findall(SESSION_PY)) + assert backends == {EXAMPLE_BACKEND}, f"divergent backends in session.py: {backends}" def test_pyproject_floor_matches_ci_matrix(): From 3d54156fb6efd341d843ed880747480597393288 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:10:06 -0700 Subject: [PATCH 16/22] Fix SPDX post-hook silently skipped by openapi-python-client openapi-python-client checks `cmd.split(" ")[0]` against PATH before running each post_hook. The `YEAR=$(date +%Y) perl ...` prefix made the "command" `YEAR=$(date`, which fails the `shutil.which` check; the hook was skipped, regeneration dropped SPDX headers, and CI's staleness gate fired. Move the year computation into Perl's BEGIN block so the leading token is `perl` and the env-prefix trick is no longer needed. --- openapi-python-client-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index d89634f..452fe37 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,6 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - - "YEAR=$(date +%Y) perl -0777 -pi -e 's/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $ENV{YEAR} IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" + - "perl -0777 -pi -e 'BEGIN { $y = (localtime)[5] + 1900 } s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." From 33f91b4a2387c5170dc82fad99632a61ef4901fe Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:14:14 -0700 Subject: [PATCH 17/22] Drop BEGIN wrapper from SPDX post-hook year computation (localtime)[5]+1900 is the canonical short form; with -p the wrapper's once-per-run benefit is moot (one assignment per file, microseconds). --- openapi-python-client-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 452fe37..c0b69d6 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,6 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - - "perl -0777 -pi -e 'BEGIN { $y = (localtime)[5] + 1900 } s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" + - "perl -0777 -pi -e '$y=(localtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." From 296b200776641eee8d482e9119077cad3cb14374 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:38:45 -0700 Subject: [PATCH 18/22] Address review feedback on 0.1.0 prep - Switch SPDX post-hook from localtime to gmtime so the stamped year is deterministic across CI runner timezones. - Drop the leading underscore on _DEFAULT_BASE_URL and _DEFAULT_TIMEOUT in ionq_client.py; both are load-bearing for the drift-pin tests (and tests/conftest.py), so the private-by-convention naming was misleading. Not added to __all__ - kept off the wildcard surface. - Retarget dead CONTRIBUTING.md#reporting-bugs and #code-structure links in bug_report.yml and pull_request_template.md to the existing #proposing-changes heading; drop the redundant reporting-guide link since the template fields already prompt for what to include. - Add .github/actions/** to zizmor.yml path filters so the new setup-uv composite action gets the same audit coverage as workflow files. --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 ++---- .github/pull_request_template.md | 4 ++-- .github/workflows/zizmor.yml | 4 ++-- ionq_core/ionq_client.py | 8 ++++---- openapi-python-client-config.yaml | 2 +- tests/conftest.py | 4 ++-- tests/integration/test_backends.py | 4 ++-- tests/test_docs_consistency.py | 10 +++++----- 8 files changed, 20 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a8155f4..bb9ed15 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,16 +7,14 @@ body: attributes: value: | Thanks for taking the time to file a bug report. Please search - [existing issues](https://github.com/ionq/ionq-core-python/issues) first, - and review the [reporting guide](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#reporting-bugs) - for what to include. + [existing issues](https://github.com/ionq/ionq-core-python/issues) first. - type: dropdown id: area attributes: label: Affected area description: | - See [code structure](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#code-structure) + See [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes) for the boundary between generated and hand-written code. options: - Generated client (regenerated from OpenAPI spec) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b89e42e..3bf0802 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,5 +10,5 @@ > [!IMPORTANT] > Most code in `ionq_core/` is auto-generated and overwritten on regeneration. -> See [CONTRIBUTING.md](../CONTRIBUTING.md#code-structure) for which files are -> safe to edit. +> See [CONTRIBUTING.md](../CONTRIBUTING.md#proposing-changes) for which files +> are safe to edit. diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 242ce4f..8c8b1a8 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -3,9 +3,9 @@ name: Audit workflows on: push: branches: [main] - paths: [".github/workflows/**"] + paths: [".github/workflows/**", ".github/actions/**"] pull_request: - paths: [".github/workflows/**"] + paths: [".github/workflows/**", ".github/actions/**"] permissions: actions: read diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index cfe50ac..910f2b2 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -28,8 +28,8 @@ except PackageNotFoundError: __version__ = "0.0.0" -_DEFAULT_BASE_URL = "https://api.ionq.co/v0.4" -_DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0) +DEFAULT_BASE_URL = "https://api.ionq.co/v0.4" +DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0) _AUTH_PREFIX = "apiKey" _AUTH_HEADER = "Authorization" @@ -37,7 +37,7 @@ def IonQClient( *, api_key: str | None = None, - base_url: str = _DEFAULT_BASE_URL, + base_url: str = DEFAULT_BASE_URL, max_retries: int | None = None, timeout: httpx.Timeout | None = None, additional_user_agent: str | None = None, @@ -130,7 +130,7 @@ def IonQClient( *filter(None, (additional_user_agent, ext.user_agent_token)), ] user_agent = " ".join(ua_parts) - effective_timeout = timeout or ext.timeout or _DEFAULT_TIMEOUT + effective_timeout = timeout or ext.timeout or DEFAULT_TIMEOUT effective_retries = next(v for v in (max_retries, ext.max_retries, DEFAULT_MAX_RETRIES) if v is not None) headers = {**ext.default_headers, "User-Agent": user_agent} diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index c0b69d6..347043c 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,6 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - - "perl -0777 -pi -e '$y=(localtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" + - "perl -0777 -pi -e '$y=(gmtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." diff --git a/tests/conftest.py b/tests/conftest.py index f58bd33..07876a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,9 @@ import pytest from ionq_core import AuthenticatedClient, Client -from ionq_core.ionq_client import _DEFAULT_BASE_URL +from ionq_core.ionq_client import DEFAULT_BASE_URL -BASE_URL = "https://test.invalid" + urlparse(_DEFAULT_BASE_URL).path +BASE_URL = "https://test.invalid" + urlparse(DEFAULT_BASE_URL).path def make_job_json(job_id, status="completed", **overrides): diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py index e46d9f2..b409400 100644 --- a/tests/integration/test_backends.py +++ b/tests/integration/test_backends.py @@ -4,7 +4,7 @@ from ionq_core import Client from ionq_core.api.backends import get_backend, get_backends -from ionq_core.ionq_client import _DEFAULT_BASE_URL +from ionq_core.ionq_client import DEFAULT_BASE_URL pytestmark = pytest.mark.integration @@ -12,7 +12,7 @@ @pytest.fixture(scope="module") def backends(): # Backends listing is unauthenticated - no API key needed. - return get_backends.sync(client=Client(base_url=_DEFAULT_BASE_URL)) + return get_backends.sync(client=Client(base_url=DEFAULT_BASE_URL)) def test_list_returns_backends(backends): diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 6ac2ce3..88987e1 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -10,7 +10,7 @@ from ionq_core import extensions, polling from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES -from ionq_core.ionq_client import _DEFAULT_BASE_URL, _DEFAULT_TIMEOUT +from ionq_core.ionq_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT from ionq_core.polling import _BACKOFF_FACTOR, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT @@ -65,7 +65,7 @@ def test_retryable_status_codes_match_runtime(): *(str(c) for c in (429, 500, 502, 503)), "520-529", f"default of {DEFAULT_MAX_RETRIES}", - f"default of {int(_DEFAULT_TIMEOUT.read)} seconds", + f"default of {int(DEFAULT_TIMEOUT.read)} seconds", ], ) def test_client_extension_docstring_pins(needle): @@ -159,17 +159,17 @@ def test_oas_patch_versions_match(): def test_spec_path_matches_default_base_url(): - # Pinning to _DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until + # Pinning to DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until # CONTRIBUTING and spec-drift.yml are updated too. Otherwise the drift workflow # would silently keep curl'ing the stale endpoint. - spec_path = f"{urlparse(_DEFAULT_BASE_URL).path}/api-docs" + spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs" assert spec_path in CONTRIB assert spec_path in SPEC_DRIFT_WF def test_default_base_url_matches_spec_servers(): spec = json.loads((ROOT / "openapi.json").read_text()) - assert spec["servers"][0]["url"] == _DEFAULT_BASE_URL + assert spec["servers"][0]["url"] == DEFAULT_BASE_URL def test_single_spdx_year_across_package(): From 10129e6b2d0b64f1e23ca6e4fe7ea771159c0edf Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:46:47 -0700 Subject: [PATCH 19/22] Generalize SessionManager backend regex and unquote enable-cache Tighten test_session_example_backend_consistent so a non-qpu example backend (e.g. "simulator") in session.py would still be caught; pass enable-cache to the composite setup-uv action as a YAML boolean instead of a quoted string. --- .github/workflows/integration.yml | 2 +- tests/test_docs_consistency.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2a9f2ab..8f81e35 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-uv with: - enable-cache: "true" + enable-cache: true - run: uv sync - name: Run integration tests env: diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 88987e1..c22f25f 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -25,7 +25,7 @@ PACKAGE_DESCRIPTION = "A client library for accessing IonQ Cloud Platform API" EXAMPLE_BACKEND = "qpu.aria-1" -_BACKEND_PATTERN = re.compile(r'SessionManager\([^)]*?"(qpu\.[^"]+)"') +_BACKEND_PATTERN = re.compile(r'SessionManager\([^)]*?"([^"]+)"') def _normalize(path: str) -> str: From 244e0cf095a8b783e61c78ff260d9a4003ad814c Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:26:46 -0700 Subject: [PATCH 20/22] Unwrap hard-wrapped prose in docs and issue templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 ++---- .github/ISSUE_TEMPLATE/feature_request.yml | 5 +---- .github/pull_request_template.md | 3 +-- CHANGELOG.md | 3 +-- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bb9ed15..96c2867 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,16 +6,14 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to file a bug report. Please search - [existing issues](https://github.com/ionq/ionq-core-python/issues) first. + Thanks for taking the time to file a bug report. Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) first. - type: dropdown id: area attributes: label: Affected area description: | - See [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes) - for the boundary between generated and hand-written code. + See [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes) for the boundary between generated and hand-written code. options: - Generated client (regenerated from OpenAPI spec) - Hand-written extensions (retry, pagination, polling, sessions, native gates, etc.) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 9e41df5..097031c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,10 +6,7 @@ body: - type: markdown attributes: value: | - Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) - before opening a new request. For API surface changes (new endpoints, - parameter names, response shapes), see - [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes). + Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) before opening a new request. For API surface changes (new endpoints, parameter names, response shapes), see [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes). - type: textarea id: description diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3bf0802..685261f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,5 +10,4 @@ > [!IMPORTANT] > Most code in `ionq_core/` is auto-generated and overwritten on regeneration. -> See [CONTRIBUTING.md](../CONTRIBUTING.md#proposing-changes) for which files -> are safe to edit. +> See [CONTRIBUTING.md](../CONTRIBUTING.md#proposing-changes) for which files are safe to edit. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9987365..3b6e9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,7 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] From 07f1709d2f320e857c110700e33c362b8ac18dc0 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:35:19 -0700 Subject: [PATCH 21/22] Bump 0.1.0 release date to open-source launch --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6e9e7..964c196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -## [0.1.0] - 2026-04-28 +## [0.1.0] - 2026-04-30 ### Added From 7160cece5804eaf09f5c8fdd98b382553466f53c Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:44:48 -0700 Subject: [PATCH 22/22] Correct 0.1.0 release date to actual launch day --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964c196..7cc30da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -## [0.1.0] - 2026-04-30 +## [0.1.0] - 2026-04-29 ### Added