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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ jobs:
- run: uv run ty check ionq_core/

test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
include:
- os: macos-latest
python-version: "3.14"
- os: windows-latest
python-version: "3.14"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -47,7 +53,7 @@ jobs:
python-version: ${{ matrix.python-version }}
enable-cache: ${{ github.event_name == 'push' }}
- run: uv sync
- run: uv run pytest ${{ matrix.python-version != '3.11' && '--no-cov' || '' }}
- run: uv run pytest ${{ (matrix.python-version != '3.11' || matrix.os != 'ubuntu-latest') && '--no-cov' || '' }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we'd want to run coverage on all targets, and then combine the output so that we can see coverage of target-specific branches. No for this PR, of course, just a big picture nice to have.


audit:
runs-on: ubuntu-latest
Expand Down
30 changes: 11 additions & 19 deletions .github/workflows/generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,27 @@ permissions:

jobs:
staleness:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
- run: uv sync --group regen
- name: Prepare spec
run: |
set -euo pipefail
if [[ -f openapi-overlay.yaml ]]; then
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
else
cp openapi.json /tmp/patched-spec.json
fi
- name: Regenerate client
run: |
uv run openapi-python-client generate \
--path /tmp/patched-spec.json \
--meta none \
--config openapi-python-client-config.yaml \
--custom-template-path custom-templates \
--output-path ionq_core \
--overwrite
run: uv run --group regen python scripts/regenerate_models.py
# Windows only proves regeneration runs: openapi-python-client writes
# files with platform-native newlines, so its output there is CRLF and
# never byte-identical to the committed (LF) files.
- name: Check for uncommitted changes
if: matrix.os != 'windows-latest'
run: |
if [[ -n "$(git status --porcelain ionq_core/)" ]]; then
echo "::error::Generated code is out of date. Run the generator and commit the results."
echo "::error::Generated code is out of date. Run 'uv run --group regen python scripts/regenerate_models.py' and commit the results."
git diff ionq_core/
exit 1
fi
24 changes: 7 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ uv run ruff format --check # format check (drop --check to apply)
uv run ty check ionq_core/ # type check
```

A [`Makefile`](Makefile) offers optional shorthand for these (`make test`, `make lint`, `make typecheck`, ...); the `uv run` commands above are the canonical, OS-independent workflow.

Coverage is measured against the hand-written modules only; the generated surface is excluded. Tests treat warnings as errors.

### Integration tests
Expand All @@ -62,25 +64,13 @@ CI runs them on a weekly schedule via the [`integration`](.github/workflows/inte
To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generated files, run:

```sh
uv sync --group regen
curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json

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

uv run openapi-python-client generate \
--path /tmp/patched-spec.json \
--meta none \
--config openapi-python-client-config.yaml \
--custom-template-path custom-templates \
--output-path ionq_core \
--overwrite
uv run --group regen python scripts/regenerate_models.py # from the committed openapi.json (+ overlay)
uv run --group regen python scripts/regenerate_models.py --sync-spec # fetch the latest upstream spec first
```

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.
`--sync-spec` downloads the current spec from <https://api.ionq.co/v0.4/api-docs> into `openapi.json` before regenerating.

[`scripts/regenerate_models.py`](scripts/regenerate_models.py) is the single source of truth for the generation command and works on any OS (`make regen` / `make sync-spec` wrap it); the [`generated`](.github/workflows/generated.yml) workflow runs it on every PR across Linux, macOS, and Windows and verifies that the committed output is current. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide the `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format.

Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream.

Expand Down
35 changes: 35 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Optional convenience wrappers; scripts/regenerate_models.py is the canonical
# regeneration workflow and works on any OS.

PYTEST_ARGS ?=

.PHONY: lint format typecheck test integration regen sync-spec check-generated

lint:
uv run ruff check
uv run ruff format --check

format:
uv run ruff format

typecheck:
uv run ty check ionq_core/

test:
uv run pytest $(PYTEST_ARGS)

integration:
uv run pytest -m integration --no-cov $(PYTEST_ARGS)

regen:
uv run --group regen python scripts/regenerate_models.py

sync-spec:
uv run --group regen python scripts/regenerate_models.py --sync-spec

check-generated: regen
@if [ -n "$$(git status --porcelain ionq_core/)" ]; then \
echo "Generated code is out of date: run 'make regen' and commit the results."; \
git diff ionq_core/; \
exit 1; \
fi
3 changes: 1 addition & 2 deletions openapi-python-client-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package_name_override: ionq_core
literal_enums: true

post_hooks:
- "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.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')"
- "python ../scripts/post_generate.py"

@splch splch Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: #96 adds 3 more hooks here - whichever lands second ports them into post_generate.py (per #95 plan, this one).

- "ruff check . --fix-only"
- "ruff format ."
43 changes: 43 additions & 0 deletions scripts/post_generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Post-generation hooks for openapi-python-client (cross-platform).

Invoked via post_hooks in openapi-python-client-config.yaml. Hides
AuthenticatedClient.token from repr and prepends SPDX/@generated headers.
"""

from __future__ import annotations

import re
from pathlib import Path

PACKAGE_DIR = Path(__file__).resolve().parent.parent / "ionq_core"

# Year of the package's first publication (v0.1.0, 2026-04-29). Fixed so that
# regeneration output is identical regardless of when it runs.
COPYRIGHT_YEAR = 2026


def main() -> None:
client_file = PACKAGE_DIR / "client.py"
client_file.write_text(

@splch splch Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

write_text emits CRLF on Windows, so regen output there isn't byte-identical (and the staleness gate is Linux-only). Add newline="\n" to both calls.

re.sub(
r"(token: str)$",
r"\1 = field(repr=False)",
client_file.read_text(encoding="utf-8"),
flags=re.MULTILINE,
),
encoding="utf-8",
newline="\n",
)

header = (
f"# SPDX-FileCopyrightText: {COPYRIGHT_YEAR} IonQ, Inc.\n"
"# SPDX-License-Identifier: Apache-2.0\n# @generated\n\n"
)
for path in PACKAGE_DIR.rglob("*.py"):
text = path.read_text(encoding="utf-8")
if not text.startswith("# SPDX-FileCopyrightText"):
path.write_text(header + text, encoding="utf-8", newline="\n")


if __name__ == "__main__":
main()
74 changes: 74 additions & 0 deletions scripts/regenerate_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Regenerate the generated client packages from the vendored OpenAPI spec.

Canonical invocation:

uv run --group regen python scripts/regenerate_models.py [--sync-spec]

Invoking this script as such ensures that the commands in here don't need to
be re-run via uv run.

Applies openapi-overlay.yaml (when present) to openapi.json, then runs
openapi-python-client with the repo's config and custom templates.
"""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
SPEC_URL = "https://api.ionq.co/v0.4/api-docs"
SPEC_FILE = REPO_ROOT / "openapi.json"
OVERLAY_FILE = REPO_ROOT / "openapi-overlay.yaml"


def _get_tool_path(name: str) -> str:
path = shutil.which(name)
if path is None:
invocation_cmd = "uv run --group regen python scripts/regenerate_models.py"
sys.exit(f"error: {name!r} not found on PATH; run via '{invocation_cmd}'")
return path


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sync-spec",
action="store_true",
help=f"download the latest spec from {SPEC_URL} before regenerating",
)
args = parser.parse_args()

if args.sync_spec:
with urllib.request.urlopen(SPEC_URL, timeout=60) as response:
SPEC_FILE.write_bytes(response.read())

with tempfile.TemporaryDirectory() as tmp_dir:
patched_spec = Path(tmp_dir) / "patched-spec.json"
if OVERLAY_FILE.exists():
cmd = [_get_tool_path("oas-patch"), "overlay", str(SPEC_FILE), str(OVERLAY_FILE), "-o", str(patched_spec)]
subprocess.run(cmd, check=True, cwd=REPO_ROOT)
else:
shutil.copyfile(SPEC_FILE, patched_spec)

# fmt: off
cmd = [
_get_tool_path("openapi-python-client"), "generate",
"--path", str(patched_spec),
"--meta", "none",
"--config", "openapi-python-client-config.yaml",
"--custom-template-path", "custom-templates",
"--output-path", "ionq_core",
"--overwrite",
]
# fmt: on
subprocess.run(cmd, check=True, cwd=REPO_ROOT)


if __name__ == "__main__":
main()