From 16b7848058da906438885bdef28e95e26d4c7e82 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:48:01 -0400 Subject: [PATCH 1/2] Remove documentation rot and drift opportunities Prose that restates code is deleted, converted to a pointer at the source of truth, or pinned by tests/test_docs_consistency.py: - Fix drift that had already happened: CONTRIBUTING still said the post-hooks "run ruff fix-and-format" after the fix hook was removed. The hook enumeration there is now a pointer, and every hook in openapi-python-client-config.yaml carries its own explanatory comment. - exceptions' module example imported nothing and referenced an undefined payload; it now imports create_job and elides the body honestly. - Remove unpinned numeric copies: _transport's docstring no longer repeats the retry codes and backoff knobs (it names RETRYABLE_STATUS_CODES and build_transport instead); ClientExtension.retryable_status_codes points at the constant rather than enumerating it; "Defaults to DEFAULT_MAX_RETRIES (2)" drops the "(2)". - Remove rot-prone literals from AGENTS.md: the "Four have non-obvious behavior" workflow count and the fixture bullet's hardcoded token and test base URL. - New pins: IonQClient's user-facing defaults (retries, timeout read and connect) now track the constants; the setup-uv composite's hardcoded python-version default tracks .python-version; and every python code fence in README and the published docstrings (12 today) must parse, so example code can no longer rot silently. --- AGENTS.md | 4 +-- CONTRIBUTING.md | 2 +- ionq_core/_transport.py | 10 ++++---- ionq_core/exceptions.py | 3 ++- ionq_core/extensions.py | 4 +-- openapi-python-client-config.yaml | 3 +++ tests/test_docs_consistency.py | 42 +++++++++++++++++++++++++++++-- 7 files changed, 55 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d94d28..12ee6d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wir - Public API in each hand-written module is declared via `__all__` at the top; `ionq_core/__init__.py` re-exports those. - Type-checked by `ty` against Python 3.11. Ruff: `target-version = "py311"`, `line-length = 120`, `select = E, F, I, UP, B, SIM, RUF`. - 100% branch coverage on hand-written code (`--cov-fail-under=100`); generated paths are in `coverage.run.omit`. New conditional branches need new tests. -- Test fixtures live in [`tests/conftest.py`](tests/conftest.py): `client` (unauth) and `auth_client` (token `"test-api-key"`, `prefix="apiKey"`), both pointing at `https://test.invalid/v0.4`. Use them; don't construct clients ad hoc. +- Test fixtures and shared helpers live in [`tests/conftest.py`](tests/conftest.py); the clients there point at a `test.invalid` base URL derived from `DEFAULT_BASE_URL`. Use them; don't construct clients ad hoc. - Mock HTTP with `httpx_mock` from `pytest-httpx`. Don't introduce `responses`, `requests-mock`, or VCR. - Integration tests are marked `pytest.mark.integration` and live in `tests/integration/`. Use the `track_job` fixture so the autouse `cleanup_jobs` fixture deletes anything you create. - `gates.py` is intentionally NumPy-free (`cmath`, `math`, nested tuples). Keep it that way. @@ -94,7 +94,7 @@ Several values are pinned in multiple files (Python floor, API base URL, the gen ## CI -Workflows live in [`.github/workflows/`](.github/workflows/) — `ls` it for the current set; each file's `on:` block documents its own triggers. Four have non-obvious behavior worth knowing about: +Workflows live in [`.github/workflows/`](.github/workflows/) — `ls` it for the current set; each file's `on:` block documents its own triggers. Some have non-obvious behavior worth knowing about: - **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This is what catches hand-edits to generated files. - **`integration.yml`** is on a weekly cron and `workflow_dispatch` only — it does not run per PR, so don't rely on it for fast feedback. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3318437..e3fcb45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ uv run openapi-python-client 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 (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` and the Q-CTRL `api_credentials` field from `repr`, keep the `Authorization` header out of repr-visible client state, route path parameters through `ionq_core._url.quote_path_param`, and run `ruff` fix-and-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 normalize the generated output and apply the security rewrites; each hook in [`openapi-python-client-config.yaml`](openapi-python-client-config.yaml) carries a comment saying what it does and why. 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/ionq_core/_transport.py b/ionq_core/_transport.py index 8df637f..85e68a6 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -5,10 +5,10 @@ `ErrorRaisingTransport` converts HTTP error responses and connection failures into structured `IonQError` exceptions; `build_transport` assembles the default -stack used by `IonQClient`. Idempotent methods are retried on status codes 429, -500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5, -max 60s); POST is never retried because the API has no idempotency keys, so a -replay after an ambiguous 5xx could duplicate billable work. +stack used by `IonQClient`. Idempotent methods are retried on the codes in +`RETRYABLE_STATUS_CODES` with bounded exponential backoff (the knobs live in +`build_transport`); POST is never retried because the API has no idempotency +keys, so a replay after an ambiguous 5xx could duplicate billable work. """ import json @@ -148,7 +148,7 @@ def build_transport( Args: max_retries: Maximum number of retry attempts. Defaults to - `DEFAULT_MAX_RETRIES` (2). + `DEFAULT_MAX_RETRIES`. retryable_status_codes: HTTP status codes that trigger a retry. Defaults to `RETRYABLE_STATUS_CODES`. verify: TLS verification (``True``/``False``, a CA bundle path, or an diff --git a/ionq_core/exceptions.py b/ionq_core/exceptions.py index 27001b9..356af12 100644 --- a/ionq_core/exceptions.py +++ b/ionq_core/exceptions.py @@ -23,10 +23,11 @@ Example: ```python from ionq_core import IonQClient, RateLimitError, AuthenticationError + from ionq_core.api.default import create_job client = IonQClient() try: - job = create_job.sync(client=client, body=payload) + job = create_job.sync(client=client, body=...) except AuthenticationError: print("Invalid API key") except RateLimitError as e: diff --git a/ionq_core/extensions.py b/ionq_core/extensions.py index bd4bc94..11d3a3d 100644 --- a/ionq_core/extensions.py +++ b/ionq_core/extensions.py @@ -112,8 +112,8 @@ class ClientExtension: event_hooks: Sync `EventHook` instances invoked on every request. async_event_hooks: Async `AsyncEventHook` instances invoked on every async request. - retryable_status_codes: HTTP status codes that should trigger a retry. - Overrides the default set (429, 500, 502, 503, 520-529). + retryable_status_codes: HTTP status codes that should trigger a retry, + overriding ``ionq_core._transport.RETRYABLE_STATUS_CODES``. max_retries: Maximum retry attempts. Overrides the default of 2. timeout: Request timeout. Overrides the default of 60 seconds. transport_wrapper: Callable that wraps the sync transport, useful for diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 759a047..696c7d8 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -3,6 +3,7 @@ package_name_override: ionq_core literal_enums: true post_hooks: + # Keep the IonQ API key out of AuthenticatedClient's attrs-generated repr. - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" # Merge the Authorization header into a method-local dict instead of writing it # into self._headers, which is repr-visible and caller-owned (the key would leak @@ -18,4 +19,6 @@ post_hooks: # Also squeeze trailing newlines to one so generated output satisfies # pre-commit's end-of-file-fixer without fighting the staleness gate. - "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/;s/\\n+\\z/\\n/' $(find . -name '*.py')" + # Format the rendered package __init__.py, the one generated file that ruff's + # excludes don't skip; CI's staleness gate depends on this output byte-for-byte. - "ruff format ." diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 764b08b..d16fee8 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,17 +1,19 @@ """Pin docs and config against runtime constants and each other to catch drift in CI.""" +import ast import json import re +import textwrap import tomllib from pathlib import Path from urllib.parse import urlparse import pytest -from ionq_core import extensions, polling +from ionq_core import exceptions, extensions, gates, pagination, polling, session from ionq_core._transport import DEFAULT_MAX_RETRIES, MAX_RETRY_AFTER from ionq_core.exceptions import RateLimitError -from ionq_core.ionq_client import _AUTH_HEADER, _AUTH_PREFIX, DEFAULT_BASE_URL, DEFAULT_TIMEOUT +from ionq_core.ionq_client import _AUTH_HEADER, _AUTH_PREFIX, DEFAULT_BASE_URL, DEFAULT_TIMEOUT, IonQClient from ionq_core.polling import _BACKOFF_FACTOR, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT @@ -73,6 +75,36 @@ def test_rate_limit_cap_docstring_pin(): assert f"{int(MAX_RETRY_AFTER)} seconds" in (RateLimitError.__doc__ or "") +@pytest.mark.parametrize( + "needle", + [ + f"Defaults to {DEFAULT_MAX_RETRIES}. Set to 0", + f"{int(DEFAULT_TIMEOUT.read)} seconds with a {int(DEFAULT_TIMEOUT.connect)}-second connect timeout", + ], +) +def test_ionq_client_docstring_pins(needle): + """The defaults quoted in IonQClient's user-facing docstring track the constants.""" + assert needle in (IonQClient.__doc__ or ""), f"{needle!r} missing from IonQClient docstring" + + +def test_prose_code_examples_parse(): + """Every ```python fence in README and the published docstrings is valid Python.""" + docs = {"README.md": (ROOT / "README.md").read_text(), "IonQClient": IonQClient.__doc__ or ""} + for mod in (exceptions, extensions, gates, pagination, polling, session): + docs[mod.__name__] = mod.__doc__ or "" + for name in mod.__all__: + docs[f"{mod.__name__}.{name}"] = getattr(mod, name).__doc__ or "" + for name, text in docs.items(): + for snippet in re.findall(r"```python\n(.*?)```", text, flags=re.DOTALL): + code = textwrap.dedent(snippet) + if ">>>" in code: # doctest-style: parse only the prompt lines + code = "\n".join(line.lstrip()[4:] for line in code.splitlines() if line.lstrip().startswith(">>> ")) + try: + ast.parse(code) + except SyntaxError as exc: + pytest.fail(f"unparseable example in {name}: {exc}") + + def test_pyproject_floor_matches_ci_matrix(): assert _python_floor() == min(_ci_python_versions()) @@ -81,6 +113,12 @@ def test_python_version_file_matches_floor(): assert (ROOT / ".python-version").read_text().strip() == _python_floor() +def test_setup_uv_action_default_matches_floor(): + """The composite action's hardcoded python-version default tracks .python-version.""" + action = (ROOT / ".github" / "actions" / "setup-uv" / "action.yml").read_text() + assert f'default: "{_python_floor()}"' in action + + def test_ruff_target_version_matches_floor(): floor = _python_floor() target = PYPROJECT["tool"]["ruff"]["target-version"] From 3f6d9265917fb4a41d4dba8c488404ace001a6ea Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:12:47 -0400 Subject: [PATCH 2/2] Shorten and simplify prose across hand-written files Prose-only pass over comments, docstrings, and docs: delete what restates the code, tighten what stays, keep constraints and why-notes. No behavior change; generated files untouched. --- .gitattributes | 3 +- .github/CODEOWNERS | 1 - .github/ISSUE_TEMPLATE/bug_report.yml | 10 +-- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/workflows/spec-drift.yml | 5 +- AGENTS.md | 44 +++++------ CONTRIBUTING.md | 38 +++++---- README.md | 40 +++++----- SECURITY.md | 14 ++-- ionq_core/_transport.py | 70 ++++++----------- ionq_core/_url.py | 10 +-- ionq_core/exceptions.py | 88 ++++++--------------- ionq_core/extensions.py | 89 ++++++++-------------- ionq_core/gates.py | 49 +++--------- ionq_core/ionq_client.py | 74 +++++------------- ionq_core/pagination.py | 72 ++++------------- ionq_core/polling.py | 52 +++++-------- ionq_core/session.py | 32 +++----- openapi-overlay.yaml | 12 +-- openapi-python-client-config.yaml | 13 ++-- tests/conftest.py | 2 +- tests/integration/conftest.py | 4 +- tests/integration/test_async.py | 4 +- tests/integration/test_backends.py | 2 +- tests/test_docs_consistency.py | 12 +-- tests/test_exceptions.py | 3 +- tests/test_extensions.py | 6 -- tests/test_ionq_client.py | 11 +-- tests/test_models.py | 3 +- tests/test_pagination.py | 3 +- tests/test_polling.py | 3 +- tests/test_transport.py | 15 ++-- tests/test_url.py | 8 +- 34 files changed, 268 insertions(+), 528 deletions(-) diff --git a/.gitattributes b/.gitattributes index 431d8ac..0bb4a05 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -# Auto-generated by openapi-python-client. +# Generated by openapi-python-client. ionq_core/__init__.py linguist-generated=true ionq_core/client.py linguist-generated=true ionq_core/errors.py linguist-generated=true @@ -9,5 +9,4 @@ ionq_core/models/** linguist-generated=true # Vendored upstream OpenAPI spec. openapi.json linguist-generated=true -# Lockfile. uv.lock linguist-generated=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a9d9a45..581ec18 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ -# Default owners for everything in the repo. * @ionq/developer-tools diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 96c2867..955e92b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,14 +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. + Search [existing issues](https://github.com/ionq/ionq-core-python/issues) before filing. - 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 generated vs hand-written boundary. options: - Generated client (regenerated from OpenAPI spec) - Hand-written extensions (retry, pagination, polling, sessions, native gates, etc.) @@ -28,7 +28,7 @@ body: id: what-happened attributes: label: What happened? - description: A clear description of the bug, including any error message or traceback. + description: Include any error message or traceback. validations: required: true @@ -36,13 +36,13 @@ body: id: expected attributes: label: What did you expect to happen? - description: Optional - skip if a traceback or error message above already shows the problem. + description: Optional - skip if the error above already shows the problem. - type: textarea id: reproduction attributes: label: Reproduction - description: Minimal code or steps to reproduce the bug. + description: Minimal code or steps to reproduce. render: Python validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 56d5feb..7f44a38 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,4 +5,4 @@ contact_links: 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. + about: Account, billing, or platform questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 097031c..bca518b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,7 +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). + Search [existing issues](https://github.com/ionq/ionq-core-python/issues) first. For API surface changes (endpoints, parameters, 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/workflows/spec-drift.yml b/.github/workflows/spec-drift.yml index 2e66d05..6cedbe5 100644 --- a/.github/workflows/spec-drift.yml +++ b/.github/workflows/spec-drift.yml @@ -14,9 +14,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 env: - # Pinned (tests/test_docs_consistency.py keeps it aligned with DEFAULT_BASE_URL); - # never derived from openapi.json, so a tampered vendored spec cannot point the - # check at a mirror that hides it. + # Pinned, never derived from openapi.json, so a tampered spec cannot redirect this + # check. tests/test_docs_consistency.py keeps it aligned with DEFAULT_BASE_URL. SPEC_URL: https://api.ionq.co/v0.4/api-docs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/AGENTS.md b/AGENTS.md index 12ee6d2..3240f16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Instructions for AI agents working in this repository. Humans should read [`CONT ## What this is -`ionq-core` is a typed, sync+async Python REST client for the [IonQ Cloud Platform API](https://api.ionq.co/v0.4). Most of `ionq_core/` is **generated** from `openapi.json` via `openapi-python-client`; a small **hand-written** layer at the package root adds retries, hooks, pagination, polling, sessions, structured exceptions, and native-gate unitaries. Apache-2.0, published to PyPI as `ionq-core` (see `pyproject.toml` `[project] version` and `classifiers` for current release status). Most end users should pick a higher-level wrapper (`qiskit-ionq`, `cirq-ionq`, `pennylane-ionq`, CUDA-Q, qbraid) — `ionq-core` is the wire-level building block those SDKs sit on. +`ionq-core` is a typed, sync+async Python REST client for the [IonQ Cloud Platform API](https://api.ionq.co/v0.4). Most of `ionq_core/` is **generated** from `openapi.json` via `openapi-python-client`; a small **hand-written** layer at the package root adds retries, hooks, pagination, polling, sessions, structured exceptions, and native-gate unitaries. Apache-2.0, on PyPI as `ionq-core` (see `pyproject.toml` `[project] version` and `classifiers` for release status). Most end users want a higher-level wrapper (`qiskit-ionq`, `cirq-ionq`, `pennylane-ionq`, CUDA-Q, qbraid); `ionq-core` is the wire-level building block those SDKs sit on. ## Setup @@ -13,7 +13,7 @@ uv sync # canonical; uv.lock is committed and CI runs UV_FROZEN=t uvx pre-commit install ``` -`uv` is required. Don't use `pip` / `poetry` for dev workflows — they bypass the lockfile. +`uv` is required for dev workflows; `pip` / `poetry` bypass the lockfile. ## Run @@ -31,14 +31,14 @@ uv run pytest -m integration --no-cov `pyproject.toml` is the source of truth for these invocations. Tests treat warnings as errors and use `xfail_strict=True`. -## File boundary — the most important rule +## File boundary - the most important rule `ionq_core/` has two layers: -- **Generated** — overwritten on every regeneration. The set is enumerated in [`.gitattributes`](.gitattributes) (`linguist-generated=true` lines) and mirrored in `pyproject.toml`'s `ruff.extend-exclude` + `coverage.run.omit`; `tests/test_docs_consistency.py` keeps the three lists aligned. The one exception is `ionq_core/__init__.py`, which is in `.gitattributes` only — its content is rendered from [`custom-templates/package_init.py.jinja`](custom-templates/package_init.py.jinja) but the rendered output is still linted and coverage-checked. -- **Hand-written** — everything else under `ionq_core/`. Extend, fix bugs, add tests. +- **Generated** - overwritten on every regeneration. Listed in [`.gitattributes`](.gitattributes) (`linguist-generated=true` lines) and mirrored in `pyproject.toml`'s `ruff.extend-exclude` + `coverage.run.omit`; `tests/test_docs_consistency.py` keeps the three lists aligned. Exception: `ionq_core/__init__.py` is in `.gitattributes` only - it renders from [`custom-templates/package_init.py.jinja`](custom-templates/package_init.py.jinja), but the rendered output is still linted and coverage-checked. +- **Hand-written** - everything else under `ionq_core/`. Extend, fix bugs, add tests. -To check whether a file is generated, look at `.gitattributes`: +To check whether a file is generated: ```sh grep -E '^ionq_core/' .gitattributes @@ -51,7 +51,7 @@ When you hit a bug in generated code: ## Regenerating the client -Run the block in [`CONTRIBUTING.md`](CONTRIBUTING.md#regenerating-the-client) verbatim; CI runs the same invocation via [`generated.yml`](.github/workflows/generated.yml) on every PR. The spec source is `https://api.ionq.co/v0.4/api-docs` (if that version 404s, search for the current one). Commit regenerated files in the same PR as the spec/template/overlay change that produced them. +Run the block in [`CONTRIBUTING.md`](CONTRIBUTING.md#regenerating-the-client) verbatim; [`generated.yml`](.github/workflows/generated.yml) runs the same invocation on every PR. The spec source is `https://api.ionq.co/v0.4/api-docs` (if that version 404s, find the current one). Commit regenerated files in the same PR as the spec/template/overlay change that produced them. ## Calling generated endpoints @@ -71,37 +71,37 @@ get_jobs.sync(client=client, status="completed", limit=10) # query only create_job.sync(client=client, body=payload) # body only ``` -Use `next_=` (trailing underscore) for the cursor pagination kwarg — Python keyword collision. The `iter_jobs` / `aiter_jobs` / `iter_session_jobs` / `aiter_session_jobs` helpers handle paging for you. +Use `next_=` (trailing underscore) for the cursor pagination kwarg - Python keyword collision. `iter_jobs` / `aiter_jobs` / `iter_session_jobs` / `aiter_session_jobs` page for you. -`UNSET` (sentinel from `ionq_core.types`) means "field omitted"; `None` serializes as JSON `null`. `to_dict()` skips `UNSET` and emits `null` for `None`. Don't conflate. +`UNSET` (sentinel from `ionq_core.types`) means "field omitted"; `None` serializes as JSON `null`. `to_dict()` skips `UNSET` and emits `null` for `None`. -Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wire header is `Authorization: apiKey {token}`. Don't change this. +Auth is `apiKey`, **not** `Bearer`: `IonQClient` sets `prefix="apiKey"` and the wire header is `Authorization: apiKey {token}`. Don't change this. ## Hand-written conventions -- Every `.py` carries an SPDX header (`# SPDX-FileCopyrightText: IonQ, Inc.` + `Apache-2.0`); generated files also carry `# @generated`. The year must be **uniform across the whole package** — `tests/test_docs_consistency.py` fails CI otherwise. At the year boundary, bump every hand-written file to match (the generator post-hook does the rest). -- Public API in each hand-written module is declared via `__all__` at the top; `ionq_core/__init__.py` re-exports those. +- Every `.py` carries an SPDX header (`# SPDX-FileCopyrightText: IonQ, Inc.` + `Apache-2.0`); generated files also carry `# @generated`. The year must be **uniform across the whole package** or `tests/test_docs_consistency.py` fails CI. At the year boundary, bump every hand-written file to match; the generator post-hook does the rest. +- Each hand-written module declares its public API in `__all__` at the top; `ionq_core/__init__.py` re-exports those. - Type-checked by `ty` against Python 3.11. Ruff: `target-version = "py311"`, `line-length = 120`, `select = E, F, I, UP, B, SIM, RUF`. - 100% branch coverage on hand-written code (`--cov-fail-under=100`); generated paths are in `coverage.run.omit`. New conditional branches need new tests. -- Test fixtures and shared helpers live in [`tests/conftest.py`](tests/conftest.py); the clients there point at a `test.invalid` base URL derived from `DEFAULT_BASE_URL`. Use them; don't construct clients ad hoc. +- Fixtures and shared helpers live in [`tests/conftest.py`](tests/conftest.py); its clients point at a `test.invalid` base URL derived from `DEFAULT_BASE_URL`. Use them instead of constructing clients ad hoc. - Mock HTTP with `httpx_mock` from `pytest-httpx`. Don't introduce `responses`, `requests-mock`, or VCR. - Integration tests are marked `pytest.mark.integration` and live in `tests/integration/`. Use the `track_job` fixture so the autouse `cleanup_jobs` fixture deletes anything you create. - `gates.py` is intentionally NumPy-free (`cmath`, `math`, nested tuples). Keep it that way. -## Drift sentinels — single edits that fan out +## Drift sentinels - single edits that fan out -Several values are pinned in multiple files (Python floor, API base URL, the generated-path set, numeric defaults that appear in both code and docstrings). [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py) is the canonical list of these alignments — when it fails, read the failing assertion to find the peers and update every one in the same PR. Treat that test file as the source of truth; it grows as new pinned values are added. +Several values are pinned in multiple files (Python floor, API base URL, the generated-path set, numeric defaults that appear in both code and docstrings). [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py) is the canonical, growing list of these alignments; when it fails, read the failing assertion to find the peers and update every one in the same PR. ## CI -Workflows live in [`.github/workflows/`](.github/workflows/) — `ls` it for the current set; each file's `on:` block documents its own triggers. Some have non-obvious behavior worth knowing about: +Workflows live in [`.github/workflows/`](.github/workflows/) - `ls` it for the current set; each file's `on:` block documents its own triggers. Non-obvious behavior: -- **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This is what catches hand-edits to generated files. -- **`integration.yml`** is on a weekly cron and `workflow_dispatch` only — it does not run per PR, so don't rely on it for fast feedback. +- **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This catches hand-edits to generated files. +- **`integration.yml`** runs on a weekly cron and `workflow_dispatch` only, never per PR, so don't rely on it for fast feedback. - **`spec-drift.yml`** opens or updates a `spec-drift`-labeled issue when upstream `openapi.json` diverges from the vendored copy. - **`release.yml`** triggers on `v*` tags only and refuses mismatched tag/version pairs or republishing existing PyPI versions. -When authoring a new workflow, use the local [`.github/actions/setup-uv`](.github/actions/setup-uv) composite action rather than `astral-sh/setup-uv` directly, for consistency with the existing matrix. +New workflows must use the local [`.github/actions/setup-uv`](.github/actions/setup-uv) composite action, not `astral-sh/setup-uv` directly, for consistency with the existing matrix. ## PR and release conventions @@ -112,11 +112,11 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu ## Things to avoid (and what to do instead) -- **Including IonQ confidential information** in any committed artifact — code, comments, commit messages, branch names, PR titles/bodies, test fixtures, or docstrings → scrub before pushing; the repo is public (Apache-2.0 on PyPI) and a leak can't be cleanly undone. Confidential covers proprietary algorithms, trade secrets, internal project codenames, internal file paths, server names, IP addresses, API keys, passwords, non-public experimental data, sensitive customer information, PII, and internal-only comments or documentation. +- **Including IonQ confidential information** in any committed artifact - code, comments, commit messages, branch names, PR titles/bodies, test fixtures, docstrings → scrub before pushing; the repo is public (Apache-2.0 on PyPI) and a leak can't be cleanly undone. Confidential covers proprietary algorithms, trade secrets, internal project codenames, internal file paths, server names, IP addresses, API keys, passwords, non-public experimental data, sensitive customer information, PII, and internal-only comments or documentation. - **Editing generated files by hand** → fix the spec, the overlay, the post-hooks, or the template, then regenerate. CI's `generated.yml` will catch it otherwise. -- **Adding a dependency with `pip install`** → `uv add ` (or edit `pyproject.toml` and `uv lock`). Confirm the dependency's license before adding: MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause are pre-approved. +- **Adding a dependency with `pip install`** → `uv add ` (or edit `pyproject.toml` and `uv lock`). Check its license first: MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause are pre-approved. - **`Bearer` token examples / `requests` / `aiohttp`** in docs or tests → the library is `httpx`-only and the auth prefix is `apiKey`. -- **Dropping the SPDX header or `# @generated` marker** on regenerated files → if a post-hook regression made this happen, fix `openapi-python-client-config.yaml` rather than re-adding by hand. +- **Dropping the SPDX header or `# @generated` marker** on regenerated files → a post-hook adds them, so fix `openapi-python-client-config.yaml` rather than re-adding by hand. - **Adding NumPy or any new runtime dependency** to `gates.py` → keep it pure-Python. ## Where to look first diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3fcb45..5ebd687 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,5 @@ # 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. - ## Code of conduct This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unacceptable behavior to . @@ -14,17 +12,17 @@ This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unac ## 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: +Most of `ionq-core` is generated from IonQ's OpenAPI spec and overwritten on every regeneration, so 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** -> 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. +- **API surface changes** (endpoints, parameter names, response shapes) -> these come from the upstream spec, not this repo. Open an issue describing the change you want. +- **Bugs in generated code** -> never edit files marked `linguist-generated=true` in [`.gitattributes`](.gitattributes); file an issue instead. - **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. ## 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/). `uv.lock` is canonical and CI runs with `UV_FROZEN=true`. ```sh git clone https://github.com/ionq/ionq-core-python @@ -33,7 +31,7 @@ uv sync uvx pre-commit install ``` -The supported Python floor is set by `requires-python` in `pyproject.toml`; the CI matrix in [`ci.yml`](.github/workflows/ci.yml) is the source of truth for tested interpreters. +The Python floor is `requires-python` in `pyproject.toml`; the tested interpreters are the matrix in [`ci.yml`](.github/workflows/ci.yml). ## Running checks locally @@ -44,22 +42,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 measures only the hand-written modules. Warnings are 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 deselected by default and need 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 weekly via the [`integration`](.github/workflows/integration.yml) workflow against a gated secret, so most contributions do not need them locally. ## 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: ```sh uv sync --group regen @@ -74,21 +72,21 @@ uv run openapi-python-client 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 normalize the generated output and apply the security rewrites; each hook in [`openapi-python-client-config.yaml`](openapi-python-client-config.yaml) carries a comment saying what it does and why. +Keep it in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same command on every PR. The post-hooks in [`openapi-python-client-config.yaml`](openapi-python-client-config.yaml) normalize the output and apply the security rewrites; each one is commented. -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 regenerated files with the spec or template change that caused them. [`spec-drift.yml`](.github/workflows/spec-drift.yml) checks weekly and 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. +1. Fork and branch off `main`. +2. Add or update tests for any hand-written code you touch. 3. Run the local checks above and `uvx pre-commit run --all-files`. -4. Push and open a PR against `main`. Fill in the **Summary** and **Test plan** sections of the template. -5. CI must pass: lint, tests across the supported-Python matrix, the generated-code staleness check, `pip-audit`, and `zizmor` when workflow files change. A reviewer from `@ionq/developer-tools` will review. +4. Open a PR against `main` and fill in the **Summary** and **Test plan** sections. +5. CI must pass: lint, tests across the Python matrix, the generated-code staleness check, `pip-audit`, and `zizmor` when workflow files change. `@ionq/developer-tools` reviews. -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. +Commit messages have no enforced format, but PR titles become release notes via `gh release create --generate-notes`. Write each title as a changelog line: imperative, 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. +Add user-visible changes to [CHANGELOG.md](CHANGELOG.md) under the next release section, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. ## Contributor License Agreement diff --git a/README.md b/README.md index 401c372..f9c08d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ionq-core -A client library for accessing IonQ Cloud Platform API. +A typed, async-capable Python client for the [IonQ Cloud Platform](https://ionq.com) REST 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,21 +8,19 @@ 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. 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 HTTP layer is generated from IonQ's OpenAPI specification with [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client); hand-written extensions add retries, polling, pagination, structured exceptions, and an extension API for downstream SDKs. -The full API reference is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). +## Higher-level interfaces -## Looking for a higher-level interface? +Most users should pick the integration matching their stack: -`ionq-core` is the low-level HTTP client. Most users should pick the integration that matches their existing stack: +- **Qiskit** -> [`qiskit-ionq`](https://pypi.org/project/qiskit-ionq/) +- **Cirq** -> [`cirq-ionq`](https://pypi.org/project/cirq-ionq/) +- **PennyLane** -> [`pennylane-ionq`](https://pypi.org/project/pennylane-ionq/) +- **CUDA-Q** -> IonQ is a backend in [NVIDIA CUDA-Q](https://github.com/NVIDIA/cuda-quantum). +- **Multi-vendor** -> IonQ is reachable via [`qbraid`](https://pypi.org/project/qbraid/). -- **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). -- **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. +Use this package directly for REST access close to the wire, or to build a downstream SDK on top of it. ## Installation @@ -32,7 +30,7 @@ pip install ionq-core ## Quickstart -Submit a Bell-state circuit on the cloud simulator and read the result probabilities: +Submit a Bell-state circuit to the cloud simulator and read its probabilities: ```python from ionq_core import IonQClient, wait_for_job @@ -61,25 +59,25 @@ 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. +Each generated endpoint module exposes `sync`, `sync_detailed`, `asyncio`, and `asyncio_detailed`. The plain variants return the parsed body; the `_detailed` ones return a `Response[T]` with the status code, headers, and parsed body. -For client options, error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). +For client options, error classes, retries, pagination, polling, sessions, and extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). ## Versioning -This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with `ionq_core.__version__`. +This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. The installed version is `ionq_core.__version__`. -The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core-python/blob/main/CHANGELOG.md). +Release history: [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](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. +Most of `ionq_core/` is generated from the OpenAPI spec and overwritten on every regeneration. [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md) covers the generated/hand-written boundary, development setup, and the regeneration command. ## Support -- Bug reports and feature requests: [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) -- 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) +- Bugs and feature requests: [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) +- Security disclosures: [SECURITY.md](https://github.com/ionq/ionq-core-python/blob/main/SECURITY.md) +- Account, billing, or hardware access: [ionq.com/contact](https://ionq.com/contact) ## License diff --git a/SECURITY.md b/SECURITY.md index e9e4c49..fd43035 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,17 +6,17 @@ 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. +Include enough detail to reproduce the issue, and redact your API key from any logs or 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. +- We aim to acknowledge receipt within **3 business days** and send a triage assessment within **10 business days**. +- We follow **coordinated disclosure**: do not publicly disclose, share working exploits, or notify third parties until a fix is released and an advisory is published. The default disclosure window is **90 days** from acknowledgement; we may agree on a different 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 consider security research consistent with this policy 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). @@ -24,11 +24,11 @@ When conducting security research consistent with this policy, we consider your 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 email 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. +If you are unsure whether an 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. +While `ionq-core` is in the `0.x` series, **only the latest released minor receives security fixes**. This policy will harden at `1.0`. ## Scope @@ -51,4 +51,4 @@ This policy covers the source code in this repository and the `ionq-core` distri ## Credit -We credit reporters in published advisories by default. If you prefer to remain anonymous, please tell us in your report. +We credit reporters in published advisories by default. Tell us in your report if you prefer to remain anonymous. diff --git a/ionq_core/_transport.py b/ionq_core/_transport.py index 85e68a6..0da2358 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -3,12 +3,9 @@ """Transport layer: retry via httpx-retries, error raising for IonQ API responses. -`ErrorRaisingTransport` converts HTTP error responses and connection failures -into structured `IonQError` exceptions; `build_transport` assembles the default -stack used by `IonQClient`. Idempotent methods are retried on the codes in -`RETRYABLE_STATUS_CODES` with bounded exponential backoff (the knobs live in -`build_transport`); POST is never retried because the API has no idempotency -keys, so a replay after an ambiguous 5xx could duplicate billable work. +`ErrorRaisingTransport` converts HTTP error responses and connection failures into structured +`IonQError` exceptions. `build_transport` assembles the default stack used by `IonQClient`: +idempotent methods retry on `RETRYABLE_STATUS_CODES` with bounded exponential backoff, POST never does. """ import json @@ -21,25 +18,21 @@ from .exceptions import APIConnectionError, APITimeoutError, raise_for_status RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, *range(520, 530)}) -"""HTTP status codes that trigger an automatic retry.""" DEFAULT_MAX_RETRIES: int = 2 -"""Default number of retry attempts for transient errors.""" MAX_RETRY_AFTER: float = 300.0 -"""Cap (seconds) on the server-supplied ``Retry-After``: callers are documented -to sleep on `RateLimitError.retry_after`, so a forged header must stay bounded.""" +"""Cap (seconds) on the server-supplied ``Retry-After``: callers sleep on +`RateLimitError.retry_after`, so a forged header must stay bounded.""" MAX_ERROR_BODY_BYTES: int = 64 * 1024 -"""Maximum decoded bytes read from an error response body.""" def _read_error_body(response: httpx.Response) -> bytes: """Read at most `MAX_ERROR_BODY_BYTES` decoded bytes of an error body. - Streaming with a cap (instead of ``response.read()``) keeps a small - compressed body from inflating without limit in client memory: httpx - transparently applies whatever ``Content-Encoding`` the server chose. + Streaming with a cap (not ``response.read()``) stops a small compressed body from + inflating without limit: httpx transparently applies the server's ``Content-Encoding``. """ body = bytearray() try: @@ -69,8 +62,7 @@ def _raise_for_response(response: httpx.Response, content: bytes) -> None: try: body: dict | str | None = json.loads(content) except (ValueError, UnicodeDecodeError): - # json.JSONDecodeError subclasses ValueError; UnicodeDecodeError covers - # bodies that aren't decodable in the declared (or guessed) encoding. + # json.JSONDecodeError subclasses ValueError; UnicodeDecodeError covers undecodable bodies. body = content.decode(response.encoding or "utf-8", errors="replace")[:500] or None message = (body.get("message") or body.get("error")) if isinstance(body, dict) else None try: @@ -78,8 +70,7 @@ def _raise_for_response(response: httpx.Response, content: bytes) -> None: except (KeyError, ValueError): retry_after = None else: - # float() accepts "inf" and overflow forms like "1e309"; a non-finite - # value is garbage, not advice, so treat it as absent. + # float() accepts "inf" and overflow forms like "1e309"; non-finite is garbage, not advice. retry_after = min(max(parsed, 0.0), MAX_RETRY_AFTER) if math.isfinite(parsed) else None raise_for_status(response.status_code, body, retry_after, message, request_id=response.headers.get("x-request-id")) @@ -87,20 +78,16 @@ def _raise_for_response(response: httpx.Response, content: bytes) -> None: class ErrorRaisingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): """Wraps a transport to raise structured IonQ exceptions on error responses. - For HTTP 4xx/5xx responses, reads the response body (capped at - `MAX_ERROR_BODY_BYTES` decoded bytes) and raises the appropriate - `APIError` subclass. For connection and timeout errors from httpx, - raises `APIConnectionError` or `APITimeoutError` respectively. - - This class implements both sync and async transport interfaces so a - single instance works with both ``httpx.Client`` and ``httpx.AsyncClient``. + 4xx/5xx responses become the matching `APIError` subclass (body capped at + `MAX_ERROR_BODY_BYTES`); httpx timeouts and connection failures become + `APITimeoutError` and `APIConnectionError`. One instance serves both + ``httpx.Client`` and ``httpx.AsyncClient``. Args: - transport: Inner transport for sync requests (typically a - ``RetryTransport``). - async_transport: Inner transport for async requests; defaults to - ``transport``. Separate inners let `build_transport` set TLS - options, which live on distinct sync/async httpx transports. + transport: Inner transport for sync requests (typically a ``RetryTransport``). + async_transport: Inner transport for async requests; defaults to ``transport``. + Separate inners let `build_transport` set TLS options, which live on + distinct sync/async httpx transports. """ def __init__(self, transport, async_transport=None) -> None: @@ -143,22 +130,10 @@ def build_transport( ) -> ErrorRaisingTransport: """Build the default transport stack for `IonQClient`. - Creates ``RetryTransport``s (from httpx-retries) with exponential - backoff, wrapped by `ErrorRaisingTransport` for structured error handling. - Args: - max_retries: Maximum number of retry attempts. Defaults to - `DEFAULT_MAX_RETRIES`. - retryable_status_codes: HTTP status codes that trigger a retry. - Defaults to `RETRYABLE_STATUS_CODES`. - verify: TLS verification (``True``/``False``, a CA bundle path, or an - ``ssl.SSLContext``) applied to the underlying transports; httpx - ignores client-level ``verify`` when a custom transport is - supplied, so it must be configured here to take effect. - - Returns: - A configured `ErrorRaisingTransport` ready to be passed to an - httpx client (sync or async). + verify: TLS verification (``True``/``False``, a CA bundle path, or an ``ssl.SSLContext``) + applied to the underlying transports. httpx ignores client-level ``verify`` when a + custom transport is supplied, so it must be set here to take effect. """ retry = Retry( total=max_retries, @@ -169,9 +144,8 @@ def build_transport( # POST is deliberately not retryable: without idempotency keys, a replay # after an ambiguous 5xx could duplicate billable jobs. ) - # Build the SSL context once: handing `verify` to each transport would load - # the CA bundle from disk twice per client (create_ssl_context passes an - # ssl.SSLContext through unchanged, so pinned contexts keep their identity). + # Build the SSL context once: passing `verify` to each transport would load the CA bundle + # twice. create_ssl_context passes an ssl.SSLContext through unchanged, so pinned contexts survive. ctx = httpx.create_ssl_context(verify=verify) return ErrorRaisingTransport( RetryTransport(transport=httpx.HTTPTransport(verify=ctx), retry=retry), diff --git a/ionq_core/_url.py b/ionq_core/_url.py index 9a50c7f..324716a 100644 --- a/ionq_core/_url.py +++ b/ionq_core/_url.py @@ -10,13 +10,9 @@ def quote_path_param(value: object) -> str: """Percent-encode ``value`` as a single URL path segment. - Rejects ``""``, ``"."``, and ``".."``: ``quote`` never encodes dots, so - those values would survive into the URL verbatim and collapse a fixed path - segment under RFC 3986 normalization (e.g. ``/sessions/../jobs`` -> - ``/jobs``, turning a session-scoped request into an account-wide one). - - Raises: - ValueError: If the value is ``""``, ``"."``, or ``".."``. + Raises ``ValueError`` for ``""``, ``"."``, and ``".."``: ``quote`` never encodes dots, so those + would survive into the URL verbatim and collapse a fixed segment under RFC 3986 normalization + (e.g. ``/sessions/../jobs`` -> ``/jobs``, turning a session-scoped request into an account-wide one). """ segment = str(value) if segment in ("", ".", ".."): diff --git a/ionq_core/exceptions.py b/ionq_core/exceptions.py index 356af12..64125ef 100644 --- a/ionq_core/exceptions.py +++ b/ionq_core/exceptions.py @@ -3,7 +3,7 @@ """Structured exceptions for the IonQ API client. -All exceptions inherit from `IonQError`. The hierarchy is: +All exceptions inherit from `IonQError`: ``` IonQError @@ -52,27 +52,23 @@ class IonQError(Exception): """Base exception for all IonQ errors. - Catch this to handle any error raised by the library, including connection - failures, API errors, polling timeouts, and job failures. The one - exception outside this tree is ``errors.UnexpectedStatus``, raised only - for undocumented status codes when ``raise_on_unexpected_status`` is set. + The only error outside this tree is ``errors.UnexpectedStatus``, raised for + undocumented status codes when ``raise_on_unexpected_status`` is set. """ class APIConnectionError(IonQError): """Raised when a connection to the IonQ API cannot be established. - This covers DNS resolution failures, refused connections, and other - network-level errors. The original ``httpx`` exception is chained - via ``__cause__``. + Covers DNS failures, refused connections, and other network-level errors. + The original ``httpx`` exception is chained via ``__cause__``. """ class APITimeoutError(APIConnectionError): """Raised when a request to the IonQ API times out. - Inherits from `APIConnectionError` so that catching connection errors - also catches timeouts. + Also caught by ``except APIConnectionError``. """ @@ -81,14 +77,13 @@ class APIError(IonQError): Attributes: status_code: The HTTP status code. - body: The parsed response body (``dict`` if JSON, ``str`` otherwise, - or ``None`` if the body could not be read). - message: A human-readable error message extracted from the response, - or a default ``"HTTP "`` string. + body: Parsed response body (``dict`` if JSON, ``str`` otherwise, + ``None`` if it could not be read). + message: Error message from the response, or ``"HTTP "``. retry_after: Seconds to wait before retrying, from the ``Retry-After`` - header, or ``None`` if the server did not send a usable one. - request_id: The ``x-request-id`` header from the response, useful for - contacting IonQ support about a specific request. + header, or ``None`` if the server sent no usable one. + request_id: The ``x-request-id`` response header; quote it when + contacting IonQ support. """ def __init__( @@ -109,30 +104,20 @@ def __init__( class AuthenticationError(APIError): - """Raised on ``401 Unauthorized``. - - Typically means the API key is missing, invalid, or revoked. - """ + """Raised on ``401 Unauthorized``: the API key is missing, invalid, or revoked.""" class PermissionDeniedError(APIError): - """Raised on ``403 Forbidden``. - - The API key is valid but lacks permission for the requested operation. - """ + """Raised on ``403 Forbidden``: the API key is valid but lacks permission for the operation.""" class NotFoundError(APIError): - """Raised on ``404 Not Found``. - - The requested resource (job, session, backend, etc.) does not exist. - """ + """Raised on ``404 Not Found``: the job, session, backend, etc. does not exist.""" class BadRequestError(APIError): - """Raised on ``400 Bad Request``. + """Raised on ``400 Bad Request``: the body or query params failed server-side validation. - The request body or query parameters failed server-side validation. Inspect ``body`` for details. """ @@ -140,26 +125,17 @@ class BadRequestError(APIError): class RateLimitError(APIError): """Raised on ``429 Too Many Requests``. - The client has exceeded the API rate limit. The ``retry_after`` attribute - indicates how many seconds to wait before retrying, if the server provided - a ``Retry-After`` header. - Attributes: - retry_after: Seconds to wait before retrying, or ``None`` if the - server did not include a usable ``Retry-After`` header. The - default transport validates the header and caps the value at - 300 seconds (non-finite values are treated as absent), so a - hostile or buggy server cannot steer callers that sleep on this - attribute into an unbounded wait. + retry_after: Seconds to wait before retrying, or ``None`` if the server + sent no usable ``Retry-After`` header. The default transport caps + it at 300 seconds (non-finite values count as absent), so a hostile + or buggy server cannot push callers that sleep on it into an + unbounded wait. """ class ServerError(APIError): - """Raised on ``5xx`` server errors. - - These are typically transient and are automatically retried by the default - transport (see `IonQClient`). - """ + """Raised on ``5xx``. Usually transient; the default transport retries these (see `IonQClient`).""" _STATUS_TO_EXCEPTION: dict[int, type[APIError]] = { @@ -179,25 +155,9 @@ def raise_for_status( *, request_id: str | None = None, ) -> None: - """Raise an appropriate `APIError` subclass for an HTTP error status. + """Raise the `APIError` subclass matching an HTTP error status; a no-op below 400. - Does nothing for status codes below 400. - - Args: - status_code: The HTTP status code. - body: The parsed response body. - retry_after: Value from the ``Retry-After`` header, if present. - message: A human-readable error message. - request_id: The ``x-request-id`` response header. - - Raises: - BadRequestError: On 400. - AuthenticationError: On 401. - PermissionDeniedError: On 403. - NotFoundError: On 404. - RateLimitError: On 429. - ServerError: On 5xx. - APIError: On other 4xx codes. + 5xx raises `ServerError`; any other unmapped 4xx raises `APIError`. """ if status_code < 400: return diff --git a/ionq_core/extensions.py b/ionq_core/extensions.py index 11d3a3d..adc669d 100644 --- a/ionq_core/extensions.py +++ b/ionq_core/extensions.py @@ -3,10 +3,9 @@ """Extension API for downstream SDKs building on ionq-core. -This module provides the `ClientExtension` configuration bundle and the -`EventHook` / `AsyncEventHook` protocols that allow downstream SDKs to -customize client behavior without modifying this library. Extensions are -passed to `IonQClient` via the ``extension`` parameter. +Pass a `ClientExtension` to `IonQClient` via the ``extension`` parameter to +customize client behavior without forking this library. `EventHook` and +`AsyncEventHook` observe individual requests. Example: ```python @@ -47,47 +46,33 @@ def on_response(self, request: httpx.Request, response: httpx.Response) -> None: class EventHook(Protocol): """Protocol for observing HTTP requests and responses (sync). - Implement this protocol and pass instances via - `ClientExtension.event_hooks` to receive callbacks on every request. + Pass instances via `ClientExtension.event_hooks`. - Hooks may also define an optional ``on_error(request, exc)`` method, - fired before a transport exception is re-raised. It is looked up by name - and deliberately not part of this protocol, so minimal hooks still pass + Hooks may also define an optional ``on_error(request, exc)`` method, fired + before a transport exception is re-raised. It is looked up by name and + deliberately not part of this protocol, so minimal hooks still pass ``isinstance`` checks. - Hook exceptions are logged and suppressed by default. Set - ``debug_hooks=True`` on `ClientExtension` to re-raise them instead. + Hook exceptions are logged and suppressed unless `ClientExtension` sets + ``debug_hooks=True``. """ def on_request(self, request: httpx.Request) -> None: - """Called after the request is built but before it is sent. - - Args: - request: The outgoing HTTP request. - """ + """Called after the request is built, before it is sent.""" ... def on_response(self, request: httpx.Request, response: httpx.Response) -> None: """Called after a successful response is received. Not called for error responses: the wrapped transport raises an - `IonQError` before this hook fires. Define ``on_error`` to observe - failures. - - Args: - request: The original HTTP request. - response: The HTTP response. + `IonQError` first. Define ``on_error`` to observe failures. """ ... @runtime_checkable class AsyncEventHook(Protocol): - """Async counterpart of `EventHook` for the async client path. - - Implement this protocol and pass instances via - `ClientExtension.async_event_hooks`. - """ + """Async counterpart of `EventHook`. Pass instances via `ClientExtension.async_event_hooks`.""" async def on_request(self, request: httpx.Request) -> None: """Async counterpart of `EventHook.on_request`.""" @@ -102,28 +87,25 @@ async def on_response(self, request: httpx.Request, response: httpx.Response) -> class ClientExtension: """Declarative configuration bundle for downstream SDK integration. - All fields are optional. Pass an instance to `IonQClient` via the - ``extension`` parameter to customize client behavior. + All fields are optional. Attributes: user_agent_token: Extra token appended to the ``User-Agent`` header (e.g. ``"my-sdk/1.0"``). default_headers: Headers merged into every request. - event_hooks: Sync `EventHook` instances invoked on every request. - async_event_hooks: Async `AsyncEventHook` instances invoked on - every async request. - retryable_status_codes: HTTP status codes that should trigger a retry, - overriding ``ionq_core._transport.RETRYABLE_STATUS_CODES``. + event_hooks: Hooks fired on every sync request. + async_event_hooks: Hooks fired on every async request. + retryable_status_codes: Status codes that trigger a retry, overriding + ``ionq_core._transport.RETRYABLE_STATUS_CODES``. max_retries: Maximum retry attempts. Overrides the default of 2. timeout: Request timeout. Overrides the default of 60 seconds. - transport_wrapper: Callable that wraps the sync transport, useful for - adding middleware (e.g. caching, tracing). - async_transport_wrapper: Callable that wraps the async transport. - error_mapper: Callable that maps exceptions raised by the transport - to downstream-specific exception types. Return the original - exception to leave it unchanged. - debug_hooks: If ``True``, hook exceptions are re-raised instead of - being logged and suppressed. Useful during development. + transport_wrapper: Wraps the sync transport, for middleware such as + caching or tracing. + async_transport_wrapper: Wraps the async transport. + error_mapper: Maps transport exceptions to downstream-specific types. + Return the original exception to leave it unchanged. + debug_hooks: Re-raise hook exceptions instead of logging and + suppressing them. Useful during development. """ user_agent_token: str | None = None @@ -168,21 +150,14 @@ async def _afire_hooks(hooks: tuple, method: str, *args, debug: bool = False) -> class HookTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): """Transport decorator that invokes `EventHook` instances and optionally maps exceptions. - Wraps an inner transport, firing hook callbacks before and after each - request. If a request raises an exception, ``on_error`` hooks are fired - and the optional ``error_mapper`` is applied before re-raising. - - This class implements both ``httpx.BaseTransport`` and - ``httpx.AsyncBaseTransport``, so a single instance can be used for - both sync and async clients. - - Args: - transport: The inner transport to wrap. - hooks: Tuple of `EventHook` or `AsyncEventHook` instances. - debug: If ``True``, hook exceptions are re-raised instead of - being logged and suppressed. - error_mapper: Optional callable that maps transport exceptions - to custom exception types. + ``hooks`` holds `EventHook` or `AsyncEventHook` instances: ``on_request`` + fires before the inner transport runs, ``on_response`` after it returns. + If that transport raises, ``on_error`` hooks fire and ``error_mapper`` is + applied before re-raising. ``debug`` re-raises hook exceptions instead of + logging and suppressing them. + + Implements both ``httpx.BaseTransport`` and ``httpx.AsyncBaseTransport``, + so one instance serves both sync and async clients. """ def __init__( diff --git a/ionq_core/gates.py b/ionq_core/gates.py index 5badd45..b81e337 100644 --- a/ionq_core/gates.py +++ b/ionq_core/gates.py @@ -5,17 +5,8 @@ All functions return nested tuples of complex numbers (no NumPy dependency). -**Parameter conventions:** - -- Phase parameters (``phi``, ``phi0``, ``phi1``) are in **turns** - - fractions of 2*pi. So ``phi=0.25`` means pi/2 radians. -- Interaction parameters (``angle``) are in **units of pi**. So - ``angle=0.25`` means pi/4 radians. - -**Type aliases:** - -- `Matrix2x2` - ``tuple[tuple[complex, complex], tuple[complex, complex]]`` -- `Matrix4x4` - 4x4 nested tuple of complex numbers +- Phase parameters (``phi``, ``phi0``, ``phi1``) are in turns (fractions of 2*pi): ``phi=0.25`` is pi/2 radians. +- Interaction parameters (``angle``) are in units of pi: ``angle=0.25`` is pi/4 radians. Example: ```python @@ -34,7 +25,7 @@ import math Matrix2x2 = tuple[tuple[complex, complex], tuple[complex, complex]] -"""Type alias for a 2x2 unitary matrix (single-qubit gate).""" +"""2x2 unitary matrix (single-qubit gate).""" Matrix4x4 = tuple[ tuple[complex, complex, complex, complex], @@ -42,7 +33,7 @@ tuple[complex, complex, complex, complex], tuple[complex, complex, complex, complex], ] -"""Type alias for a 4x4 unitary matrix (two-qubit gate).""" +"""4x4 unitary matrix (two-qubit gate).""" _2PI = 2 * math.pi @@ -50,19 +41,14 @@ def gpi_matrix(phi: float) -> Matrix2x2: r"""Single-qubit GPI gate. - Matrix form: ``[[0, e^{-i*2*pi*phi}], [e^{i*2*pi*phi}, 0]]`` - - At ``phi=0`` this is the Pauli X gate. + Matrix: ``[[0, e^{-i*2*pi*phi}], [e^{i*2*pi*phi}, 0]]``, the Pauli X gate at ``phi=0``. Args: - phi: Phase angle in turns (fractions of 2*pi). - - Returns: - A `Matrix2x2` unitary matrix. + phi: Phase angle in turns. Examples: ```python - >>> gpi_matrix(0) # Pauli X + >>> gpi_matrix(0) ((0, (1+0j)), ((1+0j), 0)) ``` """ @@ -74,11 +60,7 @@ def gpi2_matrix(phi: float) -> Matrix2x2: """Single-qubit GPI2 gate (pi/2 rotation about an axis in the XY plane). Args: - phi: Phase angle in turns (fractions of 2*pi) defining the - rotation axis in the XY plane. - - Returns: - A `Matrix2x2` unitary matrix. + phi: Phase angle in turns, setting the rotation axis. """ e = cmath.exp(1j * _2PI * phi) s = 1 / math.sqrt(2) @@ -88,16 +70,10 @@ def gpi2_matrix(phi: float) -> Matrix2x2: def ms_matrix(phi0: float, phi1: float, angle: float = 0.25) -> Matrix4x4: """Two-qubit Molmer-Sorensen (MS) gate. - The default ``angle=0.25`` produces a maximally-entangling gate. - Args: phi0: Frame rotation phase for qubit 0 in turns. phi1: Frame rotation phase for qubit 1 in turns. - angle: Interaction angle in units of pi. Defaults to 0.25 - (i.e. pi/4 radians). - - Returns: - A `Matrix4x4` unitary matrix. + angle: Interaction angle in units of pi. Defaults to 0.25, which is maximally entangling. """ a = math.pi * angle ca, sa = math.cos(a), math.sin(a) @@ -114,15 +90,10 @@ def ms_matrix(phi0: float, phi1: float, angle: float = 0.25) -> Matrix4x4: def zz_matrix(angle: float) -> Matrix4x4: """Two-qubit ZZ interaction gate. - Diagonal matrix: ``diag(e^{-i*pi*a}, e^{i*pi*a}, e^{i*pi*a}, e^{-i*pi*a})`` - - At ``angle=0`` this is the identity gate. + Matrix: ``diag(e^{-i*pi*a}, e^{i*pi*a}, e^{i*pi*a}, e^{-i*pi*a})``, the identity at ``angle=0``. Args: angle: Interaction angle in units of pi. - - Returns: - A `Matrix4x4` unitary matrix. """ em = cmath.exp(-1j * math.pi * angle) ep = cmath.exp(1j * math.pi * angle) diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 2eaa43b..cebe91b 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -3,10 +3,8 @@ """IonQ-specific client convenience wrapper. -The `IonQClient` factory function is the recommended way to create an API client. -It reads the API key from the environment, configures retries with exponential -backoff, sets a descriptive User-Agent header, and wires up both the sync and -async httpx transports. +`IonQClient` builds an `AuthenticatedClient` with the API key from the environment, a descriptive User-Agent, and +retrying sync and async transports. """ __all__ = ["IonQClient", "__version__"] @@ -34,8 +32,7 @@ _AUTH_HEADER = "Authorization" -# Factory named in PascalCase (deliberately, not a class) so call sites read -# like construction. Returns the generated `AuthenticatedClient`. +# PascalCase deliberately (not a class) so call sites read like construction. def IonQClient( *, api_key: str | None = None, @@ -49,58 +46,33 @@ def IonQClient( """Create an authenticated IonQ API client. Args: - api_key: IonQ API key. If not provided, reads the ``IONQ_API_KEY`` - environment variable. + api_key: IonQ API key. Defaults to the ``IONQ_API_KEY`` environment variable. base_url: API base URL. Defaults to the IonQ production API. - max_retries: Maximum retry attempts for transient errors (429, 5xx). - Defaults to 2. Set to 0 to disable retries. - timeout: Request timeout as an ``httpx.Timeout`` instance. Defaults to - 60 seconds with a 10-second connect timeout. - additional_user_agent: Extra token appended to the User-Agent header, - useful for identifying calling applications. - extension: A `ClientExtension` bundle provided by a downstream SDK. - Allows injecting hooks, custom headers, transport wrappers, and - error mappers. - **kwargs: Passed through to `AuthenticatedClient`. ``verify_ssl`` - (``True``/``False``, a CA bundle path, or an ``ssl.SSLContext``) - is also applied to the underlying httpx transports on both the - sync and async paths. ``headers`` are merged beneath the - extension defaults and the generated ``User-Agent``; ``cookies`` - reach both the sync and async clients. ``httpx_args`` is - reserved: the transport slot is owned by `IonQClient`. + max_retries: Maximum retries for transient errors (429, 5xx). Defaults to 2. Set to 0 to disable retries. + timeout: Request timeout. Defaults to 60 seconds with a 10-second connect timeout. + additional_user_agent: Extra token appended to the User-Agent header. + extension: Hooks, custom headers, transport wrappers, and error mappers from a downstream SDK. + **kwargs: Passed through to `AuthenticatedClient`. ``verify_ssl`` (``True``/``False``, a CA bundle path, or + an ``ssl.SSLContext``) also reaches the underlying httpx transports on both the sync and async paths. + ``headers`` are merged beneath the extension defaults and the generated ``User-Agent``; ``cookies`` reach + both the sync and async clients. ``httpx_args`` is reserved: `IonQClient` owns the transport slot. Returns: - An `AuthenticatedClient` configured with retry transport and - authentication headers, ready for both sync and async API calls. + An `AuthenticatedClient` ready for both sync and async API calls. Raises: ValueError: If no API key is provided and ``IONQ_API_KEY`` is not set. Examples: - Basic usage with environment variable: - ```python from ionq_core import IonQClient from ionq_core.api.backends import get_backends - client = IonQClient() + client = IonQClient() # reads IONQ_API_KEY backends = get_backends.sync(client=client) ``` - Explicit configuration: - - ```python - import httpx - from ionq_core import IonQClient - - client = IonQClient( - api_key="your-api-key", - max_retries=5, - timeout=httpx.Timeout(30.0, connect=10.0), - ) - ``` - - Async usage with context manager: + The client also works as an async context manager: ```python async with IonQClient() as client: @@ -137,13 +109,11 @@ def IonQClient( 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) - # Caller headers are merged here (extension defaults and the User-Agent - # win) rather than forwarded, which would collide with this dict in - # AuthenticatedClient(**kwargs). + # Caller headers are merged here (extension defaults and the User-Agent win) rather than forwarded, which would + # collide with this dict in AuthenticatedClient(**kwargs). headers = {**(kwargs.pop("headers", None) or {}), **ext.default_headers, "User-Agent": user_agent} - # httpx ignores client-level `verify` when a custom transport is supplied, - # so the caller's verify_ssl must be plumbed into the transports here. + # httpx ignores client-level `verify` when a custom transport is supplied, so verify_ssl goes into the transports. sync_transport = async_transport = build_transport( effective_retries, ext.retryable_status_codes or RETRYABLE_STATUS_CODES, @@ -179,11 +149,9 @@ def IonQClient( httpx_args={"transport": sync_transport}, **kwargs, ) - # `set_async_httpx_client` bypasses `AuthenticatedClient`'s lazy auth-header - # injection, so `Authorization` is merged in manually. TLS is carried by - # `async_transport`. `_follow_redirects` is private on the generated client - # but is the only way to mirror the caller's choice here; do not add a - # public accessor in the hand-written layer. + # `set_async_httpx_client` bypasses `AuthenticatedClient`'s lazy auth-header injection, so `Authorization` is + # merged in manually; TLS rides on `async_transport`. `_follow_redirects` is private but is the only way to mirror + # the caller's choice here; do not add a public accessor in the hand-written layer. client.set_async_httpx_client( httpx.AsyncClient( base_url=base_url, diff --git a/ionq_core/pagination.py b/ionq_core/pagination.py index 6433ba7..ca3718e 100644 --- a/ionq_core/pagination.py +++ b/ionq_core/pagination.py @@ -3,9 +3,7 @@ """Pagination helpers for cursor-based IonQ API endpoints. -The IonQ API returns paginated results with a ``next`` cursor. The helpers -in this module wrap the raw endpoint calls and automatically follow cursors, -yielding individual job objects. +Each helper wraps a raw endpoint call, follows the ``next`` cursor, and yields individual jobs. Example: ```python @@ -38,8 +36,7 @@ def _check_cursor(cursor: str, seen: set[str], label: str) -> None: - # The server-controlled cursor is the loop's only exit condition; an empty - # or repeating cursor must abort rather than iterate forever. + # The server-controlled cursor is the loop's only exit condition: empty or repeated means abort, not loop forever. if not cursor or cursor in seen: raise IonQError(f"Pagination cursor for {label} did not advance (next={cursor!r}); aborting") seen.add(cursor) @@ -85,24 +82,21 @@ def iter_jobs( submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, ) -> Iterator[Job]: - """Iterate over all jobs, automatically following pagination cursors. + """Iterate over all jobs, following pagination cursors. Args: client: An authenticated API client. - status: Filter by job status (e.g. ``"completed"``, ``"failed"``). + status: Filter by job status. target: Filter by backend target name. session_id: Filter by session ID. submitter_id: Filter by submitter user ID. - limit: Maximum number of jobs per page (server default applies - if unset). + limit: Jobs per page, not a total cap; the server default applies if unset. Yields: - Individual job objects across all pages. + Job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response, or if the - pagination cursor is empty or fails to advance (which would - otherwise loop forever). + IonQError: If the API returns ``None``, or if the cursor is empty or repeats, which would loop forever. """ return _paginate( get_jobs.sync, @@ -125,24 +119,7 @@ def aiter_jobs( submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, ) -> AsyncIterator[Job]: - """Async version of `iter_jobs`. - - Args: - client: An authenticated API client. - status: Filter by job status. - target: Filter by backend target name. - session_id: Filter by session ID. - submitter_id: Filter by submitter user ID. - limit: Maximum number of jobs per page. - - Yields: - Individual job objects across all pages. - - Raises: - IonQError: If the API returns a ``None`` response, or if the - pagination cursor is empty or fails to advance (which would - otherwise loop forever). - """ + """Async version of `iter_jobs`.""" return _apaginate( get_jobs.asyncio, "jobs", @@ -164,25 +141,21 @@ def iter_session_jobs( submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, ) -> Iterator[Job]: - """Iterate over all jobs in a specific session. - - Like `iter_jobs`, but scoped to a single session. + """Like `iter_jobs`, but scoped to one session. Args: client: An authenticated API client. - session_id: The session ID to list jobs for. + session_id: The session to list jobs for. status: Filter by job status. target: Filter by backend target name. submitter_id: Filter by submitter user ID. - limit: Maximum number of jobs per page. + limit: Jobs per page, not a total cap; the server default applies if unset. Yields: - Individual job objects across all pages. + Job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response, or if the - pagination cursor is empty or fails to advance (which would - otherwise loop forever). + IonQError: If the API returns ``None``, or if the cursor is empty or repeats, which would loop forever. """ return _paginate( get_session_jobs.sync, @@ -205,24 +178,7 @@ def aiter_session_jobs( submitter_id: str | Unset = UNSET, limit: int | Unset = UNSET, ) -> AsyncIterator[Job]: - """Async version of `iter_session_jobs`. - - Args: - client: An authenticated API client. - session_id: The session ID to list jobs for. - status: Filter by job status. - target: Filter by backend target name. - submitter_id: Filter by submitter user ID. - limit: Maximum number of jobs per page. - - Yields: - Individual job objects across all pages. - - Raises: - IonQError: If the API returns a ``None`` response, or if the - pagination cursor is empty or fails to advance (which would - otherwise loop forever). - """ + """Async version of `iter_session_jobs`.""" return _apaginate( get_session_jobs.asyncio, "session jobs", diff --git a/ionq_core/polling.py b/ionq_core/polling.py index 023aaf7..117a04d 100644 --- a/ionq_core/polling.py +++ b/ionq_core/polling.py @@ -1,13 +1,9 @@ # SPDX-FileCopyrightText: 2026 IonQ, Inc. # SPDX-License-Identifier: Apache-2.0 -"""Job polling helpers for waiting on quantum job completion. +"""Job polling helpers. -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 `_BACKOFF_FACTOR` each -iteration up to `_MAX_INTERVAL`; the default total wait is -`_DEFAULT_TIMEOUT` seconds. +`wait_for_job` (or `async_wait_for_job`) blocks until a job reaches a terminal state, polling with backoff. Example: ```python @@ -51,10 +47,9 @@ class JobTimeoutError(IonQError): """Raised when a job does not reach a terminal state within the timeout. Attributes: - job_id: The ID of the job that timed out. - timeout: The timeout value in seconds that was exceeded. - last_status: The last observed status before the timeout - (e.g. ``"running"``, ``"submitted"``). + job_id: The job that timed out. + timeout: The exceeded timeout, in seconds. + last_status: Last status seen before the timeout, e.g. ``"submitted"``. """ def __init__(self, job_id: str, timeout: float, last_status: str) -> None: @@ -68,9 +63,8 @@ class JobFailedError(IonQError): """Raised when a polled job reaches ``"failed"`` status. Attributes: - job_id: The ID of the failed job. - failure: The failure detail object from the API response, or ``None`` - if no failure details were provided. + job_id: The failed job. + failure: Failure detail from the API response, or ``None`` if the response carried none. """ def __init__(self, job_id: str, failure: object) -> None: @@ -96,27 +90,22 @@ def wait_for_job( """Poll a job until it reaches a terminal state. Terminal states are ``"completed"``, ``"failed"``, and ``"canceled"``. - Polling starts at ``poll_interval`` and increases by 1.5x each - iteration, capped at 30 seconds. + Polling backs off 1.5x per attempt, capped at 30 seconds. Args: client: An authenticated API client. job_id: The UUID of the job to poll. - poll_interval: Initial interval between polls in seconds. - Defaults to 1.0. - timeout: Maximum total wait time in seconds. Defaults to 300 - (5 minutes). - raise_on_failure: If ``True`` (the default), raise `JobFailedError` - when the job status is ``"failed"``. If ``False``, return the - failed job response instead. + poll_interval: Seconds before the first re-poll. Defaults to 1.0. + timeout: Maximum total wait, in seconds. Defaults to 300. + raise_on_failure: Raise `JobFailedError` on a ``"failed"`` status. If ``False``, return the failed response. Returns: - The final job response once a terminal state is reached. + The job response in its terminal state. Raises: JobTimeoutError: If the job does not finish within ``timeout``. - JobFailedError: If ``raise_on_failure`` is ``True`` and the job fails. - IonQError: If the API returns a ``None`` response. + JobFailedError: If ``raise_on_failure`` and the job fails. + IonQError: If the API returns ``None``. """ deadline = time.monotonic() + timeout interval = poll_interval @@ -146,18 +135,17 @@ async def async_wait_for_job( Args: client: An authenticated API client. job_id: The UUID of the job to poll. - poll_interval: Initial interval between polls in seconds. - Defaults to 1.0. - timeout: Maximum total wait time in seconds. Defaults to 300. - raise_on_failure: If ``True``, raise `JobFailedError` on failure. + poll_interval: Seconds before the first re-poll. Defaults to 1.0. + timeout: Maximum total wait, in seconds. Defaults to 300. + raise_on_failure: Raise `JobFailedError` on a ``"failed"`` status. Returns: - The final job response once a terminal state is reached. + The job response in its terminal state. Raises: JobTimeoutError: If the job does not finish within ``timeout``. - JobFailedError: If ``raise_on_failure`` is ``True`` and the job fails. - IonQError: If the API returns a ``None`` response. + JobFailedError: If ``raise_on_failure`` and the job fails. + IonQError: If the API returns ``None``. """ deadline = time.monotonic() + timeout interval = poll_interval diff --git a/ionq_core/session.py b/ionq_core/session.py index 4ccec11..e60d511 100644 --- a/ionq_core/session.py +++ b/ionq_core/session.py @@ -3,9 +3,7 @@ """Session lifecycle manager for IonQ QPU sessions. -Sessions allow you to reserve priority access to a QPU backend. The -`SessionManager` class wraps the session create / end / status APIs -and supports both sync and async context managers for automatic cleanup. +A session reserves priority access to a QPU backend. `SessionManager` wraps the create / end / status APIs. Example: ```python @@ -13,7 +11,7 @@ client = IonQClient() - # Context manager creates and automatically ends the session + # Exiting the context manager ends the session with SessionManager(client, "qpu.aria-1", max_jobs=10) as session: print(session.session_id) print(session.status()) # "started" @@ -48,19 +46,18 @@ class SessionManager: """Convenience wrapper around session create / end / status APIs. - Can be used as both a sync and async context manager. On exit the - session is automatically ended. Exceptions during close are logged - and suppressed so that cleanup does not mask the original error. + Works as a sync or async context manager; exit ends the session. + Errors while ending are logged and suppressed so cleanup cannot mask the original exception. Args: client: An authenticated API client. backend: The backend to create a session on (e.g. ``"qpu.aria-1"``). - max_jobs: Optional maximum number of jobs for this session. - max_time: Optional maximum session duration in minutes. - max_cost: Optional maximum cost in USD for the session. + max_jobs: Maximum jobs in the session. + max_time: Maximum session duration, in minutes. + max_cost: Maximum session cost, in USD. Examples: - Async context manager: + Async usage: ```python async with SessionManager(client, "qpu.aria-1") as session: @@ -91,19 +88,14 @@ def __init__( @classmethod def from_id(cls, client: AuthenticatedClient, session_id: str) -> SessionManager: - """Reconnect to an existing session without creating a new one. - - This is useful for resuming work with a session that was created - in a previous process or by another client. + """Reconnect to an existing session, e.g. one created by another process or client. Args: client: An authenticated API client. session_id: The ID of the existing session. Returns: - A `SessionManager` bound to the given session ID. The ``backend`` - field will be empty since it is not needed for status checks - or ending the session. + A `SessionManager` bound to that session. Its ``backend`` is empty; status and end do not need it. """ mgr = cls(client, backend="") mgr._session_id = session_id @@ -130,7 +122,7 @@ def open(self) -> None: logger.info("Opened session %s", self._session_id) def close(self) -> None: - """End the session. Suppresses exceptions so cleanup is safe.""" + """End the session. Failures are logged, not raised.""" if self._session_id is None: return try: @@ -164,7 +156,7 @@ async def async_open(self) -> None: logger.info("Opened session %s", self._session_id) async def async_close(self) -> None: - """End the session (async). Suppresses exceptions so cleanup is safe.""" + """Async version of `close`.""" if self._session_id is None: return try: diff --git a/openapi-overlay.yaml b/openapi-overlay.yaml index d371a15..31b7b16 100644 --- a/openapi-overlay.yaml +++ b/openapi-overlay.yaml @@ -3,15 +3,9 @@ info: title: ionq-core-python local OpenAPI fixes version: 0.2.0 description: | - Patches applied to openapi.json before client generation. The upstream - spec marks QisCircuitInput.qubits as optional, but the simulator - preflight rejects payloads without it (surfacing as - UnexpectedCompilationError), so we make it required locally. - - Previously this overlay also coerced QisCircuitInput.qubits from - number/double to integer/int32; upstream adopted that fix in May 2026 - (and extended it to NativeCircuitInput and JsonMultiCircuitInput), so - that action was removed. + Applied to openapi.json before client generation. Upstream marks QisCircuitInput.qubits + optional, but the simulator preflight rejects payloads without it (surfacing as + UnexpectedCompilationError), so require it locally. actions: - target: $.components.schemas.QisCircuitInput.required diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 696c7d8..da48b34 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -5,18 +5,17 @@ literal_enums: true post_hooks: # Keep the IonQ API key out of AuthenticatedClient's attrs-generated repr. - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - # Merge the Authorization header into a method-local dict instead of writing it - # into self._headers, which is repr-visible and caller-owned (the key would leak - # via repr(client) and into any other client sharing the headers dict). + # Put the auth header in a method-local dict, not self._headers: that dict is repr-visible and + # caller-owned, so the key would leak via repr(client) and into any client sharing it. - "perl -0777 -pi -e 's/self\\._headers\\[self\\.auth_header_name\\] = (.*?)\\n(.*?)headers=self\\._headers,/_auth_headers = {**self._headers, self.auth_header_name: $1}\\n$2headers=_auth_headers,/gs' client.py" # api_credentials is a Q-CTRL API key; keep it out of the attrs-generated repr # so logging/echoing a job payload cannot disclose it (SECURITY.md in-scope). - "perl -pi -e 's/^ api_credentials: str\\K$/ = _attrs_field(repr=False)/' models/qctrl_qaoa_job_creation_payload_external_settings.py" - # Route path parameters through ionq_core._url.quote_path_param, which rejects - # "", ".", and "..": quote() leaves dots unencoded, so ".." would delete a fixed - # URL segment under RFC 3986 normalization (/sessions/../jobs -> /jobs). + # Route path params through quote_path_param, which rejects "", ".", and "..": quote() leaves + # dots unencoded, so ".." would delete a fixed URL segment under RFC 3986 normalization + # (/sessions/../jobs -> /jobs). - "perl -pi -e 's/^from urllib.parse import quote$/from ..._url import quote_path_param/; s/quote\\(str\\((\\w+)\\), safe=\"\"\\)/quote_path_param($1)/g' $(find api -name '*.py')" - # Also squeeze trailing newlines to one so generated output satisfies + # Prepend the SPDX header; squeeze trailing newlines to one so generated output satisfies # pre-commit's end-of-file-fixer without fighting the staleness gate. - "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/;s/\\n+\\z/\\n/' $(find . -name '*.py')" # Format the rendered package __init__.py, the one generated file that ruff's diff --git a/tests/conftest.py b/tests/conftest.py index f9028aa..27e1ec5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): - """Scripted transport double: returns (or raises) the queued items in order.""" + """Returns (or raises) the queued items in order.""" def __init__(self, *responses): self._responses = list(responses) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 96efd09..5cec49a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -24,7 +24,7 @@ def client(api_key: str) -> AuthenticatedClient: @pytest.fixture(scope="session") def _tracked_jobs() -> list[str]: - """Session-scoped list of job IDs to delete in `cleanup_jobs`.""" + """Job IDs to delete in `cleanup_jobs`.""" return [] @@ -41,7 +41,7 @@ def _track(job_id: str) -> str: @pytest.fixture(scope="session", autouse=True) def cleanup_jobs(client: AuthenticatedClient, _tracked_jobs: list[str]): - """Delete all jobs created during the test session.""" + """Delete tracked jobs after the session.""" yield for job_id in _tracked_jobs: with contextlib.suppress(Exception): diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 89bba5e..5898cb2 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -1,4 +1,4 @@ -"""Integration tests verifying async variants work against the real API.""" +"""Integration tests for the async API variants.""" import pytest @@ -11,7 +11,7 @@ @pytest.fixture def async_client(api_key): - """Separate client instance - `async with` would close the session-scoped client for later tests.""" + """Separate client - `async with` would close the session-scoped one that later tests need.""" return IonQClient(api_key=api_key) diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py index 269b522..f24cf51 100644 --- a/tests/integration/test_backends.py +++ b/tests/integration/test_backends.py @@ -11,7 +11,7 @@ @pytest.fixture(scope="module") def backends(): - # Backends listing is unauthenticated - no API key needed. + # Backends listing needs no API key. return get_backends.sync(client=Client(base_url=DEFAULT_BASE_URL)) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index d16fee8..ef71b64 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -71,7 +71,6 @@ def test_polling_docstring_pins(fn, needle): def test_rate_limit_cap_docstring_pin(): - """The Retry-After cap documented on RateLimitError tracks MAX_RETRY_AFTER.""" assert f"{int(MAX_RETRY_AFTER)} seconds" in (RateLimitError.__doc__ or "") @@ -83,7 +82,6 @@ def test_rate_limit_cap_docstring_pin(): ], ) def test_ionq_client_docstring_pins(needle): - """The defaults quoted in IonQClient's user-facing docstring track the constants.""" assert needle in (IonQClient.__doc__ or ""), f"{needle!r} missing from IonQClient docstring" @@ -114,7 +112,6 @@ def test_python_version_file_matches_floor(): def test_setup_uv_action_default_matches_floor(): - """The composite action's hardcoded python-version default tracks .python-version.""" action = (ROOT / ".github" / "actions" / "setup-uv" / "action.yml").read_text() assert f'default: "{_python_floor()}"' in action @@ -145,7 +142,7 @@ def test_ruff_excludes_match_coverage_omits(): def test_gitattributes_covers_ruff_paths_plus_init(): - # __init__.py: hand-edited template, generated output; in ruff/coverage scope, marked linguist-generated. + # __init__.py comes from a hand-edited template: linguist-generated, but not excluded from ruff/coverage. gitattr = { _normalize(line.split()[0]) for line in GITATTRIBUTES.splitlines() @@ -156,8 +153,7 @@ def test_gitattributes_covers_ruff_paths_plus_init(): def test_spec_path_agrees_across_code_spec_docs_and_workflow(): - # An API-version bump must land everywhere at once: DEFAULT_BASE_URL, - # openapi.json, CONTRIBUTING.md, and the pinned spec-drift fetch URL. + # An API-version bump must land in all four: DEFAULT_BASE_URL, openapi.json, CONTRIBUTING.md, spec-drift.yml. api_path = urlparse(DEFAULT_BASE_URL).path spec = json.loads((ROOT / "openapi.json").read_text()) assert urlparse(spec["servers"][0]["url"]).path == api_path @@ -167,7 +163,7 @@ def test_spec_path_agrees_across_code_spec_docs_and_workflow(): def test_single_spdx_year_across_package(): - """Generated files get the year via post-hook; hand-written files must be bumped to match at year boundaries.""" + """The post-hook stamps generated files; hand-written ones need a manual bump each new year.""" years = set() for py in (ROOT / "ionq_core").rglob("*.py"): m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) @@ -188,12 +184,10 @@ def test_single_spdx_year_across_package(): ], ) def test_agents_md_pins(needle): - """Values quoted in AGENTS.md that must track code/config.""" assert needle in AGENTS, f"{needle!r} missing from AGENTS.md" def test_coverage_threshold_in_agents_md(): - """--cov-fail-under=N in AGENTS.md matches pytest addopts.""" addopts = PYPROJECT["tool"]["pytest"]["ini_options"]["addopts"] m = re.search(r"--cov-fail-under=\d+", addopts) assert m, f"--cov-fail-under not in pytest addopts: {addopts!r}" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index de47ffb..69a4256 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -47,8 +47,7 @@ def test_429_preserves_retry_after(self): assert exc_info.value.request_id == "req-789" def test_retry_after_surfaces_on_any_status(self): - # RFC 9110 allows Retry-After on e.g. 503; the parsed value must not - # be dropped just because the status is not 429. + # RFC 9110 allows Retry-After on 503, so it must survive on a non-429 status. with pytest.raises(ServerError) as exc_info: raise_for_status(503, retry_after=5.0) assert exc_info.value.retry_after == 5.0 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c2f8c51..6d2008e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1,5 +1,3 @@ -"""Tests for the extension API (ClientExtension, EventHook, transport wrappers).""" - import httpx import pytest @@ -209,8 +207,6 @@ def test_on_error_fires_on_exception(self): assert hook.responses == [] def test_on_error_not_required(self): - """Hooks without on_error are silently skipped.""" - class MinimalHook: def on_request(self, request): pass @@ -235,8 +231,6 @@ async def test_async_on_error_fires(self): assert hook.errors[0] == (request, error) async def test_async_on_error_not_required(self): - """Async hooks without on_error are silently skipped.""" - class MinimalAsyncHook: async def on_request(self, request): pass diff --git a/tests/test_ionq_client.py b/tests/test_ionq_client.py index 3173c98..7ce1b1e 100644 --- a/tests/test_ionq_client.py +++ b/tests/test_ionq_client.py @@ -74,8 +74,7 @@ def test_version_exposed(self): def test_token_not_in_repr(self): c = IonQClient(api_key="super-secret-key") assert "super-secret-key" not in repr(c) - # the credential must also stay out of repr-visible state after the - # httpx clients (and their auth headers) have been built + # the key must stay out of repr after the httpx clients (and their auth headers) are built c.get_httpx_client() c.get_async_httpx_client() assert "super-secret-key" not in repr(c) @@ -93,9 +92,7 @@ def test_cookies_reach_both_clients(self): assert c.get_async_httpx_client().cookies["a"] == "b" def test_caller_headers_dict_not_mutated(self): - # A headers dict passed by the caller is caller-owned; injecting the - # Authorization value into it would leak the key to any other client - # sharing that dict (and into repr). + # Injecting Authorization into the caller's dict would leak the key to other holders of it, and into repr. shared = {"X-Custom": "1"} c = AuthenticatedClient(base_url="https://api.invalid", token="secret-token", prefix="apiKey", headers=shared) c.get_httpx_client() @@ -131,9 +128,7 @@ def test_async_client_default_no_follow_redirects(self): class TestIonQClientTls: """verify_ssl must reach the connection-terminating transports (CWE-295). - httpx ignores client-level ``verify`` whenever a custom transport is - supplied, so these tests assert on the SSL context of the innermost - httpx transports actually used by IonQClient, on both paths. + httpx ignores client-level ``verify`` when a custom transport is supplied, so assert on the innermost transports. """ @staticmethod diff --git a/tests/test_models.py b/tests/test_models.py index 889ed5d..d788aa1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -138,8 +138,7 @@ def test_round_trip(self): class TestQctrlCredentialMasking: - """api_credentials is a Q-CTRL API key; repr()/str() of the payload models - must never disclose it (CWE-532), while the wire format stays intact.""" + """api_credentials is a Q-CTRL API key. repr()/str() must never leak it (CWE-532); to_dict() must keep it.""" SECRET = "qctrl-secret-key-123" diff --git a/tests/test_pagination.py b/tests/test_pagination.py index aa08b54..446eed8 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -71,8 +71,7 @@ def test_multiple_pages(self, httpx_mock, auth_client): class TestCursorGuard: - """The next cursor is server-controlled and the loop's only exit; a cursor - that repeats or is empty must abort instead of iterating forever (CWE-835).""" + """The server-controlled next cursor is the loop's only exit: a repeated or empty one must abort (CWE-835).""" def test_sync_repeated_cursor_raises(self, httpx_mock, auth_client): httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) diff --git a/tests/test_polling.py b/tests/test_polling.py index b7a5a8c..d03d0ba 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -6,8 +6,7 @@ from ionq_core.polling import JobFailedError, JobTimeoutError, async_wait_for_job, wait_for_job from tests.conftest import make_job_json -# Captured at import time so async tests can call the real sleep even after -# `monkeypatch.setattr("ionq_core.polling.asyncio.sleep", ...)` shadows it. +# Captured at import time so tests can call the real sleep after monkeypatching ionq_core.polling.asyncio.sleep. _real_sleep = asyncio.sleep _FAILURE = {"code": "SimulationError", "message": "boom"} diff --git a/tests/test_transport.py b/tests/test_transport.py index 2e37c2a..35acfc8 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -37,8 +37,7 @@ def _wrap(responses): class TestBuildTransport: def test_does_not_retry_post_requests(self): - # POSTs submit billable jobs/sessions and the API has no idempotency - # keys, so a retry after an ambiguous 5xx could duplicate work. + # POSTs are billable and the API has no idempotency keys, so retrying an ambiguous 5xx could duplicate work. transport = build_transport() retry = transport._transport.retry assert "POST" not in retry.allowed_methods @@ -189,17 +188,16 @@ def test_unparseable_retry_after(self): @pytest.mark.parametrize( ("header", "expected"), [ - ("9000000000", MAX_RETRY_AFTER), # absurdly large finite values are capped + ("9000000000", MAX_RETRY_AFTER), # large finite values are capped (str(MAX_RETRY_AFTER + 1), MAX_RETRY_AFTER), ("-3", 0.0), # negative values are floored - ("inf", None), # non-finite values are garbage, not advice + ("inf", None), ("1e309", None), # overflows float() to +inf ("nan", None), ], ) def test_retry_after_bounded(self, header, expected): - # Callers are documented to sleep on retry_after, so a forged header - # must never produce an unbounded or non-finite wait (CWE-1284). + # Callers sleep on retry_after, so a forged header must never cause an unbounded or non-finite wait (CWE-1284). transport, _ = _wrap([_resp(429, headers={"retry-after": header})]) with pytest.raises(RateLimitError) as exc_info: transport.handle_request(_req()) @@ -207,7 +205,7 @@ def test_retry_after_bounded(self, header, expected): class _CountingStream(httpx.SyncByteStream, httpx.AsyncByteStream): - """A large streamed body that records how many chunks were consumed.""" + """Large streamed body that counts the chunks consumed.""" def __init__(self, chunk_size=16384, chunks=1000): self.chunk = b"x" * chunk_size @@ -253,8 +251,7 @@ def test_plain_text_body_truncated_to_500(self): assert exc_info.value.body == "e" * 500 def test_json_body_exceeding_cap_degrades_to_text(self): - # Truncation invalidates the JSON, so the capped prefix is surfaced as - # text instead of being parsed into a second full-size structure. + # Truncation invalidates the JSON, so the capped prefix is surfaced as text. big = b'{"message": "' + b"a" * (MAX_ERROR_BODY_BYTES + 1000) + b'"}' transport, _ = _wrap([httpx.Response(400, content=big)]) with pytest.raises(BadRequestError) as exc_info: diff --git a/tests/test_url.py b/tests/test_url.py index 2befa07..6a44077 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -21,7 +21,7 @@ def test_rejects_segment_escaping_values(self, value): [ ("abc-123", "abc-123"), ("a.b", "a.b"), # interior dots are legitimate - ("...", "..."), # only exact dot segments escape; three dots do not + ("...", "..."), # three dots are not a dot segment ("a/../b", "a%2F..%2Fb"), # slashes cannot smuggle dot segments ("..%2F", "..%252F"), # pre-encoded input is re-encoded, not decoded ("café", "caf%C3%A9"), @@ -35,10 +35,8 @@ def test_non_string_values_are_stringified(self): class TestEndpointPathParamRejection: - """A traversal-shaped identifier must fail before any request is built: - quote() leaves "." unencoded, so ".." would otherwise collapse a fixed - path segment under RFC 3986 normalization (CWE-23), - e.g. /sessions/../jobs -> /jobs.""" + """Traversal-shaped ids must fail before a request is built: quote() leaves "." unencoded, so ".." would + collapse a fixed path segment under RFC 3986 normalization (CWE-23), e.g. /sessions/../jobs -> /jobs.""" @pytest.mark.parametrize("bad", ["..", ".", ""]) def test_session_jobs_rejects(self, auth_client, bad):