From c9886ef910fbd63d20daf8bde6d97b6f23060937 Mon Sep 17 00:00:00 2001 From: Antal Szava Date: Mon, 31 Aug 2026 15:29:44 +0200 Subject: [PATCH 1/5] Add cross-platform regeneration script and optional Makefile --- .github/workflows/ci.yml | 10 +++- .github/workflows/generated.yml | 27 +++------- CONTRIBUTING.md | 24 +++------ Makefile | 35 +++++++++++++ scripts/regenerate_models.py | 91 +++++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 Makefile create mode 100644 scripts/regenerate_models.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6985a4f..cfb33fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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' || '' }} audit: runs-on: ubuntu-latest diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index ea6db35..de4a3a7 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -12,35 +12,24 @@ 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 - name: Check for uncommitted changes + if: runner.os == 'Linux' 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a175603..78b11fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 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 the committed output is current. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format. Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ec9ff9 --- /dev/null +++ b/Makefile @@ -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 diff --git a/scripts/regenerate_models.py b/scripts/regenerate_models.py new file mode 100644 index 0000000..29a8ad9 --- /dev/null +++ b/scripts/regenerate_models.py @@ -0,0 +1,91 @@ +"""Regenerate the generated client packages from the vendored OpenAPI spec. + +Canonical invocation: + + uv run --group regen python scripts/regenerate_models.py [--sync-spec] + +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 _tool(name: str) -> str: + path = shutil.which(name) + if path is None: + sys.exit( + f"error: {name!r} not found on PATH; run via 'uv run --group regen python scripts/regenerate_models.py'" + ) + return path + + +def _run(cmd: list[str]) -> None: + print("+", " ".join(cmd)) + subprocess.run(cmd, check=True, cwd=REPO_ROOT) + + +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: + print(f"fetching {SPEC_URL}") + 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(): + _run( + [ + _tool("oas-patch"), + "overlay", + str(SPEC_FILE), + str(OVERLAY_FILE), + "-o", + str(patched_spec), + ] + ) + else: + shutil.copyfile(SPEC_FILE, patched_spec) + + _run( + [ + _tool("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", + ] + ) + + +if __name__ == "__main__": + main() From b185379f09990a87d33a29841757b8506305c17b Mon Sep 17 00:00:00 2001 From: Antal Szava Date: Mon, 31 Aug 2026 15:40:36 +0200 Subject: [PATCH 2/5] Replace perl post-generation hooks with cross-platform Python script --- openapi-python-client-config.yaml | 3 +-- scripts/post_generate.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 scripts/post_generate.py diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 347043c..c7c994d 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -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" - "ruff check . --fix-only" - "ruff format ." diff --git a/scripts/post_generate.py b/scripts/post_generate.py new file mode 100644 index 0000000..d69d264 --- /dev/null +++ b/scripts/post_generate.py @@ -0,0 +1,37 @@ +"""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 datetime +import re +from pathlib import Path + +PACKAGE_DIR = Path(__file__).resolve().parent.parent / "ionq_core" + + +def main() -> None: + client_file = PACKAGE_DIR / "client.py" + client_file.write_text( + re.sub( + r"(token: str)$", + r"\1 = field(repr=False)", + client_file.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ), + encoding="utf-8", + ) + + year = datetime.datetime.now(datetime.UTC).year + header = f"# SPDX-FileCopyrightText: {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") + + +if __name__ == "__main__": + main() From 28de2e8eac4e9ea5a1dbc0d9a998c431bfb32d49 Mon Sep 17 00:00:00 2001 From: Antal Szava Date: Mon, 31 Aug 2026 15:42:37 +0200 Subject: [PATCH 3/5] chore: contributing --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78b11fd..62c3d64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ uv run --group regen python scripts/regenerate_models.py --sync-spec # fetch t `--sync-spec` downloads the current spec from 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 the committed output is current. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format. +[`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. From c20e9424da21c768e7631e8f8de1fe24c1fdc511 Mon Sep 17 00:00:00 2001 From: Antal Szava Date: Mon, 31 Aug 2026 16:10:34 +0200 Subject: [PATCH 4/5] chore: formatting --- scripts/regenerate_models.py | 57 +++++++++++++----------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/scripts/regenerate_models.py b/scripts/regenerate_models.py index 29a8ad9..a00d0b0 100644 --- a/scripts/regenerate_models.py +++ b/scripts/regenerate_models.py @@ -4,6 +4,9 @@ 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. """ @@ -24,20 +27,14 @@ OVERLAY_FILE = REPO_ROOT / "openapi-overlay.yaml" -def _tool(name: str) -> str: +def _get_tool_path(name: str) -> str: path = shutil.which(name) if path is None: - sys.exit( - f"error: {name!r} not found on PATH; run via 'uv run --group regen python scripts/regenerate_models.py'" - ) + 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 _run(cmd: list[str]) -> None: - print("+", " ".join(cmd)) - subprocess.run(cmd, check=True, cwd=REPO_ROOT) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -48,43 +45,29 @@ def main() -> None: args = parser.parse_args() if args.sync_spec: - print(f"fetching {SPEC_URL}") 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(): - _run( - [ - _tool("oas-patch"), - "overlay", - str(SPEC_FILE), - str(OVERLAY_FILE), - "-o", - str(patched_spec), - ] - ) + 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) - _run( - [ - _tool("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: 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__": From b5921eab5604a2336954b05dd525a22003e80617 Mon Sep 17 00:00:00 2001 From: Antal Szava Date: Tue, 1 Sep 2026 17:55:25 +0200 Subject: [PATCH 5/5] chore: addressing PR comments --- .github/workflows/generated.yml | 5 ++++- scripts/post_generate.py | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index de4a3a7..4da9cf8 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -25,8 +25,11 @@ jobs: - uses: ./.github/actions/setup-uv - name: Regenerate client 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: runner.os == 'Linux' + if: matrix.os != 'windows-latest' run: | if [[ -n "$(git status --porcelain ionq_core/)" ]]; then echo "::error::Generated code is out of date. Run 'uv run --group regen python scripts/regenerate_models.py' and commit the results." diff --git a/scripts/post_generate.py b/scripts/post_generate.py index d69d264..77ad189 100644 --- a/scripts/post_generate.py +++ b/scripts/post_generate.py @@ -6,12 +6,15 @@ from __future__ import annotations -import datetime 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" @@ -23,14 +26,17 @@ def main() -> None: flags=re.MULTILINE, ), encoding="utf-8", + newline="\n", ) - year = datetime.datetime.now(datetime.UTC).year - header = f"# SPDX-FileCopyrightText: {year} IonQ, Inc.\n# SPDX-License-Identifier: Apache-2.0\n# @generated\n\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") + path.write_text(header + text, encoding="utf-8", newline="\n") if __name__ == "__main__":