diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f5f52a..a9d9a45 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,2 @@ # Default owners for everything in the repo. -* @ionq/sdk-team - +* @ionq/developer-tools diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bb6f9e0..96c2867 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,20 +3,49 @@ description: Report a bug in ionq-core labels: [bug] body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) first. + + - 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. + options: + - Generated client (regenerated from OpenAPI spec) + - Hand-written extensions (retry, pagination, polling, sessions, native gates, etc.) + - API surface / OpenAPI spec (originates upstream) + - Documentation + - Tests or tooling + - Not sure + validations: + required: true + - type: textarea - id: description + id: what-happened attributes: - label: Description - description: What happened, and what did you expect to happen? + label: What happened? + description: A clear description of the bug, including any error message or traceback. validations: required: true + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + description: Optional - skip if a traceback or error message above already shows the problem. + - type: textarea id: reproduction attributes: label: Reproduction description: Minimal code or steps to reproduce the bug. render: Python + validations: + required: true - type: textarea id: version @@ -25,8 +54,16 @@ body: description: | Paste the output of: ```bash - python -c "import ionq_core; print(ionq_core.__version__); import platform; print(platform.python_version()); print(platform.platform())" + python -c "import ionq_core, sys, platform; print(ionq_core.__version__, sys.version, platform.platform())" ``` render: Text validations: required: true + + - type: checkboxes + id: searched + attributes: + label: Pre-submit checks + options: + - label: I searched existing issues and didn't find a duplicate. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2b52b82..56d5feb 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Report a security vulnerability + url: https://github.com/ionq/ionq-core-python/security/policy + about: Email security@ionq.co. Do not open a public issue. - name: IonQ Support url: https://ionq.com/contact about: For account, billing, or platform questions, contact IonQ support directly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b44b598..097031c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,11 @@ description: Suggest a feature for ionq-core labels: [enhancement] body: + - type: markdown + attributes: + value: | + Please search [existing issues](https://github.com/ionq/ionq-core-python/issues) before opening a new request. For API surface changes (new endpoints, parameter names, response shapes), see [proposing changes](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes). + - type: textarea id: description attributes: @@ -10,3 +15,11 @@ body: description: What problem would this solve, and how would you like it to work? validations: required: true + + - type: checkboxes + id: searched + attributes: + label: Pre-submit checks + options: + - label: I searched existing issues and didn't find a duplicate. + required: true diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml new file mode 100644 index 0000000..27e8d07 --- /dev/null +++ b/.github/actions/setup-uv/action.yml @@ -0,0 +1,20 @@ +name: Set up uv with Python +description: Install uv and Python with the project's pinned defaults. + +inputs: + python-version: + description: Python version to install. Defaults to the project's floor. + required: false + default: "3.12" + enable-cache: + description: Pass-through to astral-sh/setup-uv enable-cache. + required: false + default: "false" + +runs: + using: composite + steps: + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: ${{ inputs.python-version }} + enable-cache: ${{ inputs.enable-cache }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 90ad822..685261f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,6 +9,5 @@ --- > [!IMPORTANT] -> Most code in `ionq_core/` is auto-generated. Do not edit files under `ionq_core/api/`, -> `ionq_core/models/`, or `ionq_core/client.py`, `errors.py`, `types.py` directly. -> See [CONTRIBUTING.md](../CONTRIBUTING.md#code-structure) for details. +> Most code in `ionq_core/` is auto-generated and overwritten on regeneration. +> See [CONTRIBUTING.md](../CONTRIBUTING.md#proposing-changes) for which files are safe to edit. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ef4ab..4bca6c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" enable-cache: ${{ github.event_name == 'push' }} - run: uv sync - run: uv run ruff check @@ -43,7 +42,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: python-version: ${{ matrix.python-version }} enable-cache: ${{ github.event_name == 'push' }} @@ -57,8 +56,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" enable-cache: ${{ github.event_name == 'push' }} - run: uvx pip-audit --require-hashes --strict -r <(uv pip compile pyproject.toml --generate-hashes) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dc9fe57..286f799 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,9 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" + - uses: ./.github/actions/setup-uv - run: uv sync - run: uv run pdoc -o docs/ -d google ionq_core - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index 1a7dfe6..5b2aa4f 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -18,10 +18,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" - enable-cache: false + - uses: ./.github/actions/setup-uv - name: Prepare spec run: | set -euo pipefail diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index a3ae931..8f81e35 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -24,9 +24,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - uses: ./.github/actions/setup-uv with: - python-version: "3.12" enable-cache: true - run: uv sync - name: Run integration tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5226bfb..68a6a60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,10 +41,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" - enable-cache: false + - uses: ./.github/actions/setup-uv - run: uv build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 242ce4f..8c8b1a8 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -3,9 +3,9 @@ name: Audit workflows on: push: branches: [main] - paths: [".github/workflows/**"] + paths: [".github/workflows/**", ".github/actions/**"] pull_request: - paths: [".github/workflows/**"] + paths: [".github/workflows/**", ".github/actions/**"] permissions: actions: read diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 514241a..575b9a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: gitleaks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.15.12 hooks: - id: ruff-check args: [--fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d332d..7cc30da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,22 +2,26 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] - 2026-04-22 +## [Unreleased] + +## [0.1.0] - 2026-04-29 ### Added -- Auto-generated Python client from the IonQ OpenAPI v0.4 spec (endpoints, typed models, sync + async) -- `IonQClient` convenience wrapper with API key handling, configurable base URL, and User-Agent -- Retry transport with exponential backoff, idempotency-aware retry, and Retry-After support -- Structured exception hierarchy (`IonQError` -> `APIError` -> `AuthenticationError`, `RateLimitError`, etc.) -- Pagination helpers (`iter_jobs`, `aiter_jobs`, `iter_session_jobs`, `aiter_session_jobs`) -- Job polling helpers (`wait_for_job`, `async_wait_for_job`) with timeout and failure detection -- `SessionManager` for QPU session lifecycle (create, end, status, context manager) -- `ClientExtension` API for downstream SDKs to inject hooks, headers, timeouts, and transport wrappers -- Native gate unitary matrices (`gpi_matrix`, `gpi2_matrix`, `ms_matrix`, `zz_matrix`) -- OpenAPI Overlay for spec workarounds (nullable schemas, missing endpoints, gate fixes) -- 100% test coverage on hand-written code (line + branch) enforced in CI -- CI/CD: lint, type check, tests on Python 3.12-3.14, generated code staleness check, weekly spec drift detection +- `IonQClient` factory with `IONQ_API_KEY` auto-detection, configurable timeouts, and unified sync + async transports. +- Sync and async variants (`sync`, `sync_detailed`, `asyncio`, `asyncio_detailed`) for every endpoint, generated from the IonQ OpenAPI spec via `openapi-python-client`. +- Endpoint coverage: backends, characterizations, jobs (create, list, get, delete, cancel, cost, estimate, compiled file, probabilities, variant histogram/shots/probabilities), sessions (create, list, get, end, list jobs), usage, whoami. +- Structured exception hierarchy rooted at `IonQError`, with `APIError` subclasses for 400, 401, 403, 404, 429, and 5xx responses, plus `APIConnectionError` and `APITimeoutError` for transport failures. `RateLimitError` exposes `retry_after`. +- Automatic retry with exponential backoff and jitter on 429, 500, 502, 503, and 520-529 (default 2 retries), respecting `Retry-After` headers. +- `ClientExtension` configuration bundle for downstream SDKs: `EventHook` / `AsyncEventHook` protocols, `HookTransport`, custom retryable status codes, header injection, transport wrappers, and `error_mapper`. +- Pagination helpers `iter_jobs`, `aiter_jobs`, `iter_session_jobs`, and `aiter_session_jobs` that auto-follow cursor pagination. +- Polling helpers `wait_for_job` and `async_wait_for_job` with exponential backoff, `JobTimeoutError`, and `JobFailedError`. +- `SessionManager` with sync and async context-manager support, optional `max_jobs` / `max_time` / `max_cost` limits, and `SessionManager.from_id` for reconnecting to existing sessions. +- Native trapped-ion gate unitaries `gpi_matrix`, `gpi2_matrix`, `ms_matrix`, and `zz_matrix` as plain Python nested tuples (no NumPy dependency). +- Typed `attrs` request and response models with `from_dict()` / `to_dict()` and an `Unset` sentinel that distinguishes "not provided" from `None`. +- Python 3.12 - 3.14 support, `py.typed` marker, Apache-2.0 license. + +[Unreleased]: https://github.com/ionq/ionq-core-python/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ionq/ionq-core-python/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 352392a..3ae23e3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,35 +2,82 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment: +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. -Examples of unacceptable behavior: +## Scope -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information without explicit permission -- Other conduct which could reasonably be considered inappropriate +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the project maintainers at support@ionq.co. All complaints will be -reviewed and investigated. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bf4971..ba9ebc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,49 +1,68 @@ # Contributing to ionq-core -Thank you for your interest in contributing to the IonQ Python client. +Thanks for your interest in improving `ionq-core`. This guide covers how to file bugs, propose changes, set up a development environment, regenerate the client, and submit pull requests. + +## Code of conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Report unacceptable behavior to . + +## Getting help + +- **Bug reports and feature requests** -> open an issue using the [bug](.github/ISSUE_TEMPLATE/bug_report.yml) or [feature](.github/ISSUE_TEMPLATE/feature_request.yml) template. +- **Account, billing, or platform questions** -> . +- **Security vulnerabilities** -> see [SECURITY.md](SECURITY.md). Do not open a public issue. + +## Proposing changes + +`ionq-core` is generated from IonQ's OpenAPI specification, and most of the package is overwritten on every regeneration. Before opening a pull request, check where your change belongs: + +- **API surface changes** (new endpoints, parameter names, response shapes) -> these originate in the upstream OpenAPI spec, not this repo. Open an issue describing the change you want to see. +- **Bugs in generated code** -> files marked `linguist-generated=true` in [`.gitattributes`](.gitattributes) are overwritten on every regeneration; never edit them directly. File an issue rather than editing the generated output. +- **Hand-written extensions, tests, docs, type hints, tooling** -> pull requests welcome. + +For non-trivial changes, open an issue first to confirm scope before investing significant time. ## Development setup +This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency management; the `uv.lock` file is canonical and CI runs with `UV_FROZEN=true`. + ```sh -git clone https://github.com/ionq/ionq-core-python.git +git clone https://github.com/ionq/ionq-core-python cd ionq-core-python uv sync +pre-commit install ``` -## Running checks +The supported Python floor is set by `requires-python` in `pyproject.toml`; the CI matrix in [`ci.yml`](.github/workflows/ci.yml) is the source of truth for tested interpreters. + +## Running checks locally ```sh -uv run pytest # tests -uv run ruff check # lint -uv run ruff format --check # format check -uv run ty check ionq_core/ # type check +uv run pytest # unit tests; 100% branch coverage gate on hand-written code +uv run ruff check # lint +uv run ruff format --check # format check (drop --check to apply) +uv run ty check ionq_core/ # type check ``` -## Code structure +Coverage is measured against the hand-written modules only; the generated surface is excluded. Tests treat warnings as errors. -Most of the code in `ionq_core/` is **auto-generated** from the IonQ OpenAPI specification. Do not edit generated files directly -- they will be overwritten on regeneration. +### Integration tests -**Generated (do not edit):** -- `ionq_core/api/` -- endpoint modules -- `ionq_core/models/` -- request/response models -- `ionq_core/client.py`, `errors.py`, `types.py` +Tests under `tests/integration/` hit the live IonQ API. They are excluded by default and require an API key: -**Hand-written (edit freely):** -- `ionq_core/__init__.py` -- public API exports -- `ionq_core/ionq_client.py` -- IonQClient convenience wrapper -- `ionq_core/exceptions.py` -- exception hierarchy -- `ionq_core/extensions.py` -- extension API for downstream SDKs -- `ionq_core/_transport.py` -- retry transport (internal) -- `ionq_core/pagination.py` -- pagination helpers -- `ionq_core/polling.py` -- job polling helpers -- `ionq_core/gates.py` -- native gate matrices -- `ionq_core/session.py` -- session lifecycle manager -- `tests/` -- all tests +```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. ## Regenerating the client +To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generated files, run: + ```sh -curl -s https://api.ionq.co/v0.4/api-docs -o openapi.json +curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json if [ -f openapi-overlay.yaml ]; then uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json @@ -60,19 +79,22 @@ uvx openapi-python-client==0.28.3 generate \ --overwrite ``` -## Pull requests +Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format. -- Keep PRs focused on a single change. -- Add tests for new hand-written code. CI enforces 100% branch coverage on all hand-written code. -- CI must pass before merging (lint, tests, type check, generated code staleness check). -- The generated code staleness check on PRs verifies that `ionq_core/` matches what the generator produces. If it fails, regenerate and commit the result. +Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream. -## Contributor License Agreement +## Pull request workflow + +1. Fork the repository and create a topic branch off `main`. +2. Make your changes; add or update tests for any hand-written code you touch. +3. Run the local checks above and `pre-commit run --all-files`. +4. Push and open a PR against `main`. Fill in the **Summary** and **Test plan** sections of the template. +5. CI must pass: lint, tests 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. -To receive IonQ's CLA, please contact @mjk or email [opensource@ionq.com](mailto:opensource@ionq.com). +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. -## License +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. + +## Contributor License Agreement -By submitting a pull request, you represent that you have the right to license your -contribution to IonQ and the community, and agree that your contribution is licensed -under the [Apache License, Version 2.0](LICENSE). +Contributions are accepted under the project's [Apache 2.0 license](LICENSE). To receive IonQ's CLA, email . diff --git a/README.md b/README.md index 4e71c5d..a13559a 100644 --- a/README.md +++ b/README.md @@ -1,417 +1,90 @@ # ionq-core -[![PyPI version](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) -[![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) -[![API Docs](https://img.shields.io/badge/docs-API%20reference-blue)](https://ionq.github.io/ionq-core-python/) - -A Python client library for the [IonQ Cloud Platform API](https://docs.ionq.com/), providing full access to IonQ's quantum computing services. Supports both synchronous and asynchronous usage, with typed models for all request and response objects. - -Auto-generated from the [IonQ OpenAPI specification](https://docs.ionq.com/api-reference/v0.4/introduction) using [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). - -## Installation - -```sh -pip install ionq-core -``` - -Requires Python 3.12+. - -## Usage - -```python -from ionq_core import IonQClient -from ionq_core.api.backends import get_backends -from ionq_core.api.default import create_job -from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload - -# Uses IONQ_API_KEY env var by default -client = IonQClient() - -# List available backends -backends = get_backends.sync(client=client) -for backend in backends: - print(f"{backend.backend}: {backend.status} ({backend.qubits} qubits)") - -# Submit a quantum circuit -job = create_job.sync( - client=client, - body=CircuitJobCreationPayload.from_dict({ - "type": "ionq.circuit.v1", - "backend": "simulator", - "shots": 1000, - "input": { - "gateset": "qis", - "circuit": [ - {"gate": "h", "targets": [0]}, - {"gate": "cnot", "targets": [0], "controls": [1]}, - ], - }, - }), -) -print(f"Job submitted: {job.id} (status: {job.status})") -``` +A client library for accessing IonQ Cloud Platform API. -## Authentication - -IonQ uses API key authentication. Get your key from the [IonQ Cloud Console](https://cloud.ionq.com). - -```python -from ionq_core import IonQClient - -# Option 1: Set the IONQ_API_KEY environment variable (recommended) -client = IonQClient() - -# Option 2: Pass the key directly -client = IonQClient(api_key="your-api-key") - -# Option 3: Use AuthenticatedClient for full control -from ionq_core import AuthenticatedClient - -client = AuthenticatedClient( - base_url="https://api.ionq.co/v0.4", - token="your-api-key", - prefix="apiKey", - auth_header_name="Authorization", -) -``` - -Some endpoints (e.g., listing backends) do not require authentication. Use `Client` for those: - -```python -from ionq_core import Client - -client = Client(base_url="https://api.ionq.co/v0.4") -``` - -## Async usage - -Every endpoint has both sync and async variants: - -```python -import asyncio -from ionq_core import IonQClient -from ionq_core.api.backends import get_backends - - -async def main(): - client = IonQClient() - backends = await get_backends.asyncio(client=client) - for backend in backends: - print(f"{backend.backend}: {backend.status}") - - -asyncio.run(main()) -``` - -Both client types support context managers for proper connection cleanup: - -```python -async with IonQClient() as client: - backends = await get_backends.asyncio(client=client) -``` - -## Handling errors - -The client raises typed exceptions for all HTTP error responses: - -```python -from ionq_core import IonQClient, RateLimitError, AuthenticationError, ServerError - -client = IonQClient() - -try: - job = create_job.sync(client=client, body=payload) -except AuthenticationError: - print("Invalid API key") -except RateLimitError as e: - print(f"Rate limited, retry after {e.retry_after}s") -except ServerError as e: - print(f"Server error: {e.status_code}") -``` - -| Status code | Exception | -|---|---| -| 400 | `BadRequestError` | -| 401 | `AuthenticationError` | -| 403 | `PermissionDeniedError` | -| 404 | `NotFoundError` | -| 429 | `RateLimitError` | -| 5xx | `ServerError` | - -All exceptions inherit from `APIError`, which inherits from `IonQError`. Connection failures raise `APIConnectionError`; timeouts raise `APITimeoutError`. - -## Retries - -The client automatically retries transient errors (429, 500, 502, 503, 520-529) and connection/timeout failures with exponential backoff. Default: 2 retries. - -```python -client = IonQClient(max_retries=5) # more retries -client = IonQClient(max_retries=0) # disable retries -``` +[![PyPI](https://img.shields.io/pypi/v/ionq-core.svg)](https://pypi.org/project/ionq-core/) +[![Python versions](https://img.shields.io/pypi/pyversions/ionq-core.svg)](https://pypi.org/project/ionq-core/) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/ionq/ionq-core-python/blob/main/LICENSE) +[![CI](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ionq/ionq-core-python/actions/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-ionq.github.io-blue.svg)](https://ionq.github.io/ionq-core-python/) -`Retry-After` headers on 429 responses are respected. +`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. -## Pagination +The full API reference is published at [ionq.github.io/ionq-core-python](https://ionq.github.io/ionq-core-python/). -Endpoints that return paginated results have auto-pagination helpers: +## Looking for a higher-level interface? -```python -from ionq_core import IonQClient, iter_jobs +`ionq-core` is the low-level HTTP client. Most users should pick the integration that matches their existing stack: -client = IonQClient() -for job in iter_jobs(client, status="completed"): - print(job.id) -``` +- **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/). -Async: +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. -```python -from ionq_core import aiter_jobs +## Installation -async for job in aiter_jobs(client): - print(job.id) +```sh +pip install ionq-core ``` -Also available: `iter_session_jobs` / `aiter_session_jobs`. +## Quickstart -## Waiting for job completion +Submit a Bell-state circuit on the cloud simulator and read the result probabilities: ```python from ionq_core import IonQClient, wait_for_job +from ionq_core.api.default import create_job, get_job_probabilities +from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload -client = IonQClient() -job = create_job.sync(client=client, body=payload) -completed_job = wait_for_job(client, job.id, timeout=300) -print(completed_job.status) # "completed" -``` - -Polls with exponential backoff (1s initial, 30s max). Raises `JobTimeoutError` on timeout, `JobFailedError` if the job fails. Async: `async_wait_for_job`. - -## Native gate matrices - -Pure-Python unitary matrices for IonQ's native trapped-ion gates, useful for simulation and verification: - -```python -from ionq_core import gpi_matrix, gpi2_matrix, ms_matrix, zz_matrix - -# Phase parameters (phi) are in turns (fractions of 2*pi) -# Interaction parameters (angle) are in units of pi -gpi_matrix(0) # 2x2 Pauli X -gpi2_matrix(0.25) # 2x2 pi/2 rotation -ms_matrix(0, 0) # 4x4 maximally-entangling MS gate (angle defaults to 0.25) -zz_matrix(0.1) # 4x4 ZZ interaction -``` - -Matrices are returned as nested tuples of complex numbers (no numpy dependency). - -## Session management - -`SessionManager` provides a context manager for IonQ priority sessions: - -```python -from ionq_core import IonQClient, SessionManager - -client = IonQClient() - -with SessionManager(client, "qpu.aria-1", max_jobs=10, max_time=60) as session: - # submit jobs using session.session_id - print(session.session_id) - print(session.status()) - -# Reconnect to an existing session -session = SessionManager.from_id(client, "existing-session-id") -``` - -## Available endpoints - -### Backends - -| Function | Module | Auth | -|---|---|---| -| List backends | `ionq_core.api.backends.get_backends` | No | -| Get a backend | `ionq_core.api.backends.get_backend` | No | - -### Characterizations - -| Function | Module | Auth | -|---|---|---| -| List characterizations | `ionq_core.api.characterizations.get_characterizations_for_backend` | No | -| Get a characterization | `ionq_core.api.characterizations.get_characterization` | Yes | - -### Jobs - -| Function | Module | Auth | -|---|---|---| -| Create a job | `ionq_core.api.default.create_job` | Yes | -| List jobs | `ionq_core.api.default.get_jobs` | Yes | -| Get a job | `ionq_core.api.default.get_job` | Yes | -| Delete a job | `ionq_core.api.default.delete_job` | Yes | -| Delete jobs (bulk) | `ionq_core.api.default.delete_jobs` | Yes | -| Cancel a job | `ionq_core.api.default.cancel_job` | Yes | -| Cancel jobs (bulk) | `ionq_core.api.default.cancel_jobs` | Yes | -| Get job cost | `ionq_core.api.default.get_job_cost` | Yes | -| Get compiled circuit | `ionq_core.api.default.get_compiled_file` | Yes | -| Estimate job cost | `ionq_core.api.default.estimate_job_cost` | Yes | -| Get job probabilities | `ionq_core.api.default.get_job_probabilities` | Yes | -| Get variant histogram | `ionq_core.api.default.get_variant_histogram` | Yes | -| Get variant probabilities | `ionq_core.api.default.get_variant_probabilities` | Yes | -| Get variant shots | `ionq_core.api.default.get_variant_shots` | Yes | - -### Sessions - -| Function | Module | Auth | -|---|---|---| -| Create a session | `ionq_core.api.default.create_session` | Yes | -| List sessions | `ionq_core.api.default.get_sessions` | Yes | -| Get a session | `ionq_core.api.default.get_session` | Yes | -| End a session | `ionq_core.api.default.end_session` | Yes | -| List session jobs | `ionq_core.api.default.get_session_jobs` | Yes | - -### Other - -| Function | Module | Auth | -|---|---|---| -| Who am I | `ionq_core.api.whoami.get_whoami` | Yes | -| Get usage | `ionq_core.api.usage.get_usages` | Yes | - -Each endpoint module provides four functions: - -- **`sync`** - synchronous call, returns the parsed response -- **`asyncio`** - async call, returns the parsed response -- **`sync_detailed`** - synchronous call, returns `Response[T]` with status code, headers, and parsed body -- **`asyncio_detailed`** - async call, returns `Response[T]` with status code, headers, and parsed body - -## Models - -All request and response bodies are typed as [attrs](https://www.attrs.org/) classes with `from_dict()` and `to_dict()` methods: - -```python -from ionq_core.models.backend import Backend - -# Deserialize from API response dict -backend = Backend.from_dict({"backend": "qpu.aria-1", "status": "available", ...}) - -# Access typed attributes -print(backend.backend) # "qpu.aria-1" -print(backend.qubits) # 25 - -# Serialize back to dict -data = backend.to_dict() -``` - -Optional fields use the `Unset` sentinel (not `None`) to distinguish between "not provided" and "explicitly null": - -```python -from ionq_core.types import UNSET, Unset - -if not isinstance(backend.characterization_id, Unset): - print(f"Characterization: {backend.characterization_id}") -``` - -## Advanced - -### Timeouts - -```python -import httpx -from ionq_core import IonQClient - -client = IonQClient(timeout=httpx.Timeout(30.0, connect=10.0)) -``` +client = IonQClient() # reads IONQ_API_KEY from the environment -### Custom headers +body = CircuitJobCreationPayload.from_dict({ + "type": "ionq.circuit.v1", + "backend": "simulator", + "shots": 100, + "input": { + "gateset": "qis", + "circuit": [ + {"gate": "h", "targets": [0]}, + {"gate": "cnot", "control": 0, "target": 1}, + ], + }, +}) -```python -client = IonQClient().with_headers({"X-Custom-Header": "value"}) +job = create_job.sync(client=client, body=body) +completed = wait_for_job(client, job.id) +probs = get_job_probabilities.sync(uuid=job.id, client=client) +print(probs.additional_properties) ``` -### Custom HTTP client - -For full control over the HTTP layer, inject your own `httpx.Client`: - -```python -import httpx -from ionq_core import IonQClient - -custom_httpx = httpx.Client( - base_url="https://api.ionq.co/v0.4", - headers={"Authorization": "apiKey your-key"}, - timeout=60.0, -) +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. -client = IonQClient(api_key="your-key") -client.set_httpx_client(custom_httpx) -``` +For options (`api_key`, `base_url`, `max_retries`, `timeout`, `extension`), error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). -### Accessing raw responses +## Versioning -Use the `_detailed` variants to get status codes and headers: +This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with: ```python -from ionq_core.api.whoami import get_whoami - -response = get_whoami.sync_detailed(client=client) -print(response.status_code) # HTTPStatus.OK -print(response.headers) # dict of response headers -print(response.parsed) # Whoami object -print(response.content) # raw bytes -``` - -## Regenerating the client - -The client is generated from the vendored OpenAPI spec. To regenerate after API changes: - -```sh -# Fetch the latest spec -curl -s https://api.ionq.co/v0.4/api-docs -o openapi.json - -# Apply overlay if present (patches spec issues that the generator can't handle) -if [ -f openapi-overlay.yaml ]; then - uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json -else - cp openapi.json /tmp/patched-spec.json -fi - -# Regenerate (custom template preserves hand-written __init__.py exports) -uvx openapi-python-client==0.28.3 generate \ - --path /tmp/patched-spec.json \ - --meta none \ - --config openapi-python-client-config.yaml \ - --custom-template-path custom-templates \ - --output-path ionq_core \ - --overwrite +import ionq_core +print(ionq_core.__version__) ``` -### OpenAPI Overlay +The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core-python/blob/main/CHANGELOG.md). -If the upstream spec contains patterns that the code generator cannot handle, fixes are applied via an [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.1.0.html) file (`openapi-overlay.yaml`) using [oas-patch](https://pypi.org/project/oas-patch/). The overlay is declarative, version-controlled, and applied automatically during generation. The vendored `openapi.json` is always the unmodified upstream spec. When the upstream issue is resolved, delete the corresponding action from the overlay (or the entire file) and the pipeline continues to work without it. +## Contributing -## Development - -```sh -uv sync # Install dependencies -uv run pytest # Run tests -uv run ruff check # Lint -uv run ruff format --check # Check formatting -uv run ty check ionq_core/ # Type check -``` - -## Publishing - -For a new build to be accepted at PyPI, the version number in pyproject.toml must be incremented. Publishing is handled automatically via trusted publishing on tagged releases: - -```sh -git tag v0.1.0 -git push origin v0.1.0 -``` +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. -## Getting help +## Support -- **Bug reports and feature requests** - [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) -- **Account, billing, or QPU questions** - [IonQ Support](https://ionq.com/contact) -- **API documentation** - [docs.ionq.com](https://docs.ionq.com/) +- Bug reports and feature requests: [GitHub Issues](https://github.com/ionq/ionq-core-python/issues) +- Security disclosures: see [SECURITY.md](https://github.com/ionq/ionq-core-python/blob/main/SECURITY.md) +- Account, billing, or hardware-access questions: [ionq.com/contact](https://ionq.com/contact) ## License -Apache-2.0. See [LICENSE](LICENSE) for details. +Apache License 2.0. See [LICENSE](https://github.com/ionq/ionq-core-python/blob/main/LICENSE). diff --git a/SECURITY.md b/SECURITY.md index f83f3d1..4668486 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,56 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in this package, please report it -responsibly by emailing **security@ionq.co**. Do not open a public issue. +**Do not open a public GitHub issue for security vulnerabilities.** -We will acknowledge receipt within 2 business days and aim to provide an -initial assessment within 5 business days. +Report privately through either channel: + +- **GitHub Private Vulnerability Reporting** (preferred): [Report a vulnerability](https://github.com/ionq/ionq-core-python/security/advisories/new). The report is visible only to repository maintainers and people you invite to the advisory. +- **Email**: [security@ionq.co](mailto:security@ionq.co) with the subject line `[ionq-core-python]`. + +Please include enough detail to reproduce the issue, and redact your API key from any logs or response payloads you share. + +## Response Expectations + +- We aim to acknowledge receipt within **3 business days** and follow up with a triage assessment within **10 business days**. +- We follow **coordinated disclosure**. Please do not publicly disclose, share working exploits, or notify third parties until a fix is released and an advisory is published. Our default disclosure window is **90 days** from acknowledgement; we may agree on a shorter or longer timeline depending on severity and where the fix needs to land. +- For confirmed vulnerabilities in this package, we request CVEs through GitHub's CNA via the [repository security advisory](https://docs.github.com/en/code-security/security-advisories) workflow. + +## Safe Harbor + +When conducting security research consistent with this policy, we consider your research to be authorized and lawful. Specifically: + +- We will not initiate or support legal action against you for accidental, good-faith violations of this policy under applicable anti-hacking laws (such as the U.S. Computer Fraud and Abuse Act). +- We will not bring a claim against you for circumvention of technical controls under relevant anti-circumvention laws (such as DMCA section 1201). +- If a third party initiates legal action against you for activities conducted in good-faith compliance with this policy, we will take steps to make it known that your actions were authorized. + +In return, we ask that you comply with all applicable laws, make reasonable efforts to avoid privacy violations, service disruption, and destruction of data, limit testing to your own account or accounts you control, and use the channels above to discuss vulnerabilities with us. + +If you are unsure whether a planned activity is consistent with this policy, contact before proceeding. Safe harbor applies only to claims within IonQ's control; this policy does not bind independent third parties. ## Supported Versions -Only the latest release is supported with security updates. +`ionq-core` is pre-1.0. While the package is in the `0.x` series, **only the latest released minor receives security fixes**. This policy will harden once `1.0` is released. + +## Scope + +This policy covers the source code in this repository and the `ionq-core` distribution published to PyPI from it. + +### In scope + +- Supply-chain integrity of the published artifact (e.g., compromised release, tampered wheel). +- API-key leakage paths in the SDK (e.g., logging, exception messages, `repr()` output, telemetry). +- Insecure transport defaults (e.g., TLS verification, redirect handling, retry behavior that enables replay). +- Unsafe deserialization, code execution, or SSRF reachable through documented SDK usage. +- CVEs in pinned dependencies that are exploitable through documented SDK usage. + +### Out of scope + +- Vulnerabilities in IonQ's API, quantum cloud backend, control plane, or QPUs. Still email `security@ionq.co`; we will route them internally. +- Issues that require an attacker to already have arbitrary code execution in the user's Python process or write access to their environment or `IONQ_API_KEY`. +- Findings only reproducible against a forked or locally-modified copy of the SDK. +- Theoretical issues without a working proof-of-concept. + +## Credit + +We credit reporters in published advisories by default. If you prefer to remain anonymous, please tell us in your report. diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 92a9c64..910f2b2 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -28,7 +28,8 @@ except PackageNotFoundError: __version__ = "0.0.0" -_DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0) +DEFAULT_BASE_URL = "https://api.ionq.co/v0.4" +DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0) _AUTH_PREFIX = "apiKey" _AUTH_HEADER = "Authorization" @@ -36,7 +37,7 @@ def IonQClient( *, api_key: str | None = None, - base_url: str = "https://api.ionq.co/v0.4", + base_url: str = DEFAULT_BASE_URL, max_retries: int | None = None, timeout: httpx.Timeout | None = None, additional_user_agent: str | None = None, @@ -129,9 +130,8 @@ def IonQClient( *filter(None, (additional_user_agent, ext.user_agent_token)), ] user_agent = " ".join(ua_parts) - effective_timeout = timeout or ext.timeout or _DEFAULT_TIMEOUT - ext_retries = ext.max_retries if ext.max_retries is not None else DEFAULT_MAX_RETRIES - effective_retries = max_retries if max_retries is not None else ext_retries + effective_timeout = timeout or ext.timeout or DEFAULT_TIMEOUT + effective_retries = next(v for v in (max_retries, ext.max_retries, DEFAULT_MAX_RETRIES) if v is not None) headers = {**ext.default_headers, "User-Agent": user_agent} diff --git a/ionq_core/polling.py b/ionq_core/polling.py index 6c8ae5a..3f299a3 100644 --- a/ionq_core/polling.py +++ b/ionq_core/polling.py @@ -5,8 +5,9 @@ After submitting a job, use `wait_for_job` (or `async_wait_for_job`) to block until it reaches a terminal state (completed, failed, or canceled). -Polling uses exponential backoff from 1 second up to a 30-second maximum -interval. +Polling starts at `_DEFAULT_INTERVAL` and grows by `_BACKOFF_FACTOR` each +iteration up to `_MAX_INTERVAL`; the default total wait is +`_DEFAULT_TIMEOUT` seconds. Example: ```python @@ -43,6 +44,7 @@ _DEFAULT_INTERVAL = 1.0 _DEFAULT_TIMEOUT = 300.0 _MAX_INTERVAL = 30.0 +_BACKOFF_FACTOR = 1.5 class JobTimeoutError(IonQError): @@ -131,7 +133,7 @@ def wait_for_job( if time.monotonic() >= deadline: raise JobTimeoutError(job_id, timeout, job.status) time.sleep(max(0, min(interval, deadline - time.monotonic()))) - interval = min(interval * 1.5, _MAX_INTERVAL) + interval = min(interval * _BACKOFF_FACTOR, _MAX_INTERVAL) async def async_wait_for_job( @@ -172,4 +174,4 @@ async def async_wait_for_job( if time.monotonic() >= deadline: raise JobTimeoutError(job_id, timeout, job.status) await asyncio.sleep(max(0, min(interval, deadline - time.monotonic()))) - interval = min(interval * 1.5, _MAX_INTERVAL) + interval = min(interval * _BACKOFF_FACTOR, _MAX_INTERVAL) diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index d7951dc..347043c 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,6 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" - - "perl -0777 -pi -e 's/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: 2026 IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" + - "perl -0777 -pi -e '$y=(gmtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." diff --git a/pyproject.toml b/pyproject.toml index 812b584..a6fd5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ionq-core" version = "0.1.0" -description = "Python client for the IonQ Cloud Platform API" +description = "A client library for accessing IonQ Cloud Platform API" license = "Apache-2.0" license-files = ["LICENSE"] readme = "README.md" diff --git a/tests/conftest.py b/tests/conftest.py index 93c351e..07876a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,11 @@ +from urllib.parse import urlparse + import pytest from ionq_core import AuthenticatedClient, Client +from ionq_core.ionq_client import DEFAULT_BASE_URL + +BASE_URL = "https://test.invalid" + urlparse(DEFAULT_BASE_URL).path def make_job_json(job_id, status="completed", **overrides): @@ -35,13 +40,13 @@ def make_job_json(job_id, status="completed", **overrides): @pytest.fixture def client() -> Client: - return Client(base_url="https://test.invalid/v0.4") + return Client(base_url=BASE_URL) @pytest.fixture def auth_client() -> AuthenticatedClient: return AuthenticatedClient( - base_url="https://test.invalid/v0.4", + base_url=BASE_URL, token="test-api-key", prefix="apiKey", auth_header_name="Authorization", diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py index 9da8158..b409400 100644 --- a/tests/integration/test_backends.py +++ b/tests/integration/test_backends.py @@ -4,16 +4,15 @@ from ionq_core import Client from ionq_core.api.backends import get_backend, get_backends +from ionq_core.ionq_client import DEFAULT_BASE_URL pytestmark = pytest.mark.integration -BASE_URL = "https://api.ionq.co/v0.4" - @pytest.fixture(scope="module") def backends(): # Backends listing is unauthenticated - no API key needed. - return get_backends.sync(client=Client(base_url=BASE_URL)) + return get_backends.sync(client=Client(base_url=DEFAULT_BASE_URL)) def test_list_returns_backends(backends): diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py new file mode 100644 index 0000000..c22f25f --- /dev/null +++ b/tests/test_docs_consistency.py @@ -0,0 +1,185 @@ +"""Pin docs and config against runtime constants and each other to catch drift in CI.""" + +import json +import re +import tomllib +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +from ionq_core import extensions, polling +from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES +from ionq_core.ionq_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT +from ionq_core.polling import _BACKOFF_FACTOR, _MAX_INTERVAL +from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT + +ROOT = Path(__file__).parent.parent +README = (ROOT / "README.md").read_text() +PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) +GITATTRIBUTES = (ROOT / ".gitattributes").read_text() +CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() +GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() +SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() +SESSION_PY = (ROOT / "ionq_core" / "session.py").read_text() + +PACKAGE_DESCRIPTION = "A client library for accessing IonQ Cloud Platform API" +EXAMPLE_BACKEND = "qpu.aria-1" +_BACKEND_PATTERN = re.compile(r'SessionManager\([^)]*?"([^"]+)"') + + +def _normalize(path: str) -> str: + """Strip trailing /* or ** so 'ionq_core/api/*' == 'ionq_core/api/**' == 'ionq_core/api'.""" + path = path.rstrip("/") + while path.endswith(("/*", "**")): + path = path[:-2].rstrip("/") + return path + + +def _python_floor() -> str: + m = re.match(r">=(\d+\.\d+)", PYPROJECT["project"]["requires-python"]) + assert m, f"unexpected requires-python: {PYPROJECT['project']['requires-python']!r}" + return m.group(1) + + +def _ci_python_versions() -> list[str]: + ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + m = re.search(r"python-version:\s*\[([^\]]+)\]", ci_text) + assert m, "CI matrix not found in ci.yml" + return re.findall(r'"(\d+\.\d+)"', m.group(1)) + + +def _pin(text: str, package: str) -> str: + m = re.search(rf"{re.escape(package)}==(\S+)", text) + assert m, f"{package} pin not found" + return m.group(1) + + +def test_retryable_status_codes_match_runtime(): + assert frozenset({429, 500, 502, 503, *range(520, 530)}) == RETRYABLE_STATUS_CODES + + +@pytest.mark.parametrize( + "needle", + [ + *(str(c) for c in (429, 500, 502, 503)), + "520-529", + f"default of {DEFAULT_MAX_RETRIES}", + f"default of {int(DEFAULT_TIMEOUT.read)} seconds", + ], +) +def test_client_extension_docstring_pins(needle): + doc = extensions.ClientExtension.__doc__ or "" + assert needle in doc, f"{needle!r} missing from ClientExtension docstring" + + +@pytest.mark.parametrize( + "fn,needle", + [ + (polling.wait_for_job, f"{_BACKOFF_FACTOR}x"), + (polling.wait_for_job, f"{int(_MAX_INTERVAL)} seconds"), + (polling.wait_for_job, f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}"), + (polling.async_wait_for_job, f"Defaults to {int(_POLL_DEFAULT_TIMEOUT)}"), + ], +) +def test_polling_docstring_pins(fn, needle): + assert needle in (fn.__doc__ or ""), f"{needle!r} missing from {fn.__name__}" + + +def test_session_example_backend_consistent(): + backends = set(_BACKEND_PATTERN.findall(SESSION_PY)) + assert backends == {EXAMPLE_BACKEND}, f"divergent backends in session.py: {backends}" + + +def test_pyproject_floor_matches_ci_matrix(): + assert _python_floor() == min(_ci_python_versions()) + + +def test_python_version_file_matches_floor(): + assert (ROOT / ".python-version").read_text().strip() == _python_floor() + + +def test_ruff_target_version_matches_floor(): + floor = _python_floor() + target = PYPROJECT["tool"]["ruff"]["target-version"] + assert target == "py" + floor.replace(".", ""), f"ruff target-version {target!r} != floor {floor!r}" + + +def test_ty_python_version_matches_floor(): + assert PYPROJECT["tool"]["ty"]["environment"]["python-version"] == _python_floor() + + +def test_classifiers_match_ci_matrix(): + classifiers = sorted( + c.split("::")[-1].strip() + for c in PYPROJECT["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") + ) + assert sorted(_ci_python_versions()) == classifiers, f"matrix={_ci_python_versions()} classifiers={classifiers}" + + +def test_pyproject_description_canonical(): + assert PYPROJECT["project"]["description"] == PACKAGE_DESCRIPTION + + +def test_init_module_docstring_canonical(): + import ionq_core + + assert (ionq_core.__doc__ or "").strip() == PACKAGE_DESCRIPTION + + +def test_readme_tagline_canonical(): + assert PACKAGE_DESCRIPTION in README + + +def test_ruff_excludes_match_coverage_omits(): + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + coverage = {_normalize(p) for p in PYPROJECT["tool"]["coverage"]["run"]["omit"]} + assert ruff == coverage, f"ruff vs coverage divergence: {ruff ^ coverage}" + + +def test_gitattributes_covers_ruff_paths_plus_init(): + # __init__.py is generated (template-driven) but kept in scope for ruff/coverage + # because the template is hand-maintained. .gitattributes still marks it generated. + gitattr = { + _normalize(line.split()[0]) + for line in GITATTRIBUTES.splitlines() + if "linguist-generated=true" in line and line.startswith("ionq_core/") + } + ruff = {_normalize(p) for p in PYPROJECT["tool"]["ruff"]["extend-exclude"]} + assert gitattr == ruff | {"ionq_core/__init__.py"} + + +def test_openapi_python_client_versions_match(): + assert _pin(CONTRIB, "openapi-python-client") == _pin(GENERATED_WF, "openapi-python-client") + + +def test_oas_patch_versions_match(): + assert _pin(CONTRIB, "oas-patch") == _pin(GENERATED_WF, "oas-patch") + + +def test_spec_path_matches_default_base_url(): + # Pinning to DEFAULT_BASE_URL means a v0.4 -> v0.5 bump fails this test until + # CONTRIBUTING and spec-drift.yml are updated too. Otherwise the drift workflow + # would silently keep curl'ing the stale endpoint. + spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs" + assert spec_path in CONTRIB + assert spec_path in SPEC_DRIFT_WF + + +def test_default_base_url_matches_spec_servers(): + spec = json.loads((ROOT / "openapi.json").read_text()) + assert spec["servers"][0]["url"] == DEFAULT_BASE_URL + + +def test_single_spdx_year_across_package(): + """Generated files get the year injected by the openapi-python-client post-hook; + hand-written files have a static year. After a new-year regen, both sets must + be bumped together. + """ + years = set() + for py in (ROOT / "ionq_core").rglob("*.py"): + m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) + if m: + years.add(m.group(1)) + assert len(years) == 1, f"expected exactly one SPDX year, found: {years}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e7d3075..b48e961 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10,8 +10,9 @@ AsyncEventHook, HookTransport, ) +from tests.conftest import BASE_URL -_BACKENDS_URL = "https://api.ionq.co/v0.4/backends" +_BACKENDS_URL = f"{BASE_URL}/backends" class RecordingHook: diff --git a/tests/test_session.py b/tests/test_session.py index 8dd710d..2d0da94 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,12 +1,14 @@ import json +from urllib.parse import urlparse import httpx import pytest from ionq_core.exceptions import IonQError from ionq_core.session import SessionManager +from tests.conftest import BASE_URL as _BASE -_BASE = "https://test.invalid/v0.4" +_API_PATH = urlparse(_BASE).path def _session_json(session_id="sess-1", status="created", active=True): @@ -37,7 +39,7 @@ def test_creates_and_ends_session(self, httpx_mock, auth_client): assert mgr.session_id == "sess-1" reqs = httpx_mock.get_requests() - assert reqs[0].method == "POST" and reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].method == "POST" and reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) def test_end_called_on_exception(self, httpx_mock, auth_client): @@ -99,7 +101,7 @@ def test_open_close_outside_context(self, httpx_mock, auth_client): mgr.close() reqs = httpx_mock.get_requests() - assert reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) def test_open_when_already_open_raises(self, httpx_mock, auth_client): @@ -138,7 +140,7 @@ async def test_creates_and_ends_session(self, httpx_mock, auth_client): assert mgr.session_id == "sess-1" reqs = httpx_mock.get_requests() - assert reqs[0].method == "POST" and reqs[0].url.path == "/v0.4/sessions" + assert reqs[0].method == "POST" and reqs[0].url.path == f"{_API_PATH}/sessions" assert "/sessions/sess-1/end" in str(reqs[1].url) async def test_end_called_on_exception(self, httpx_mock, auth_client): diff --git a/tests/test_transport.py b/tests/test_transport.py index 8e38971..c3aa02f 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -11,8 +11,9 @@ RateLimitError, ServerError, ) +from tests.conftest import BASE_URL -_URL = "https://api.ionq.co/v0.4/backends" +_URL = f"{BASE_URL}/backends" class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):