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..4da9cf8 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a175603..62c3d64 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 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. 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/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..77ad189 --- /dev/null +++ b/scripts/post_generate.py @@ -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( + 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() diff --git a/scripts/regenerate_models.py b/scripts/regenerate_models.py new file mode 100644 index 0000000..a00d0b0 --- /dev/null +++ b/scripts/regenerate_models.py @@ -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()