Skip to content

Commit e184d04

Browse files
authored
Add AGENTS.md with drift checks (#40)
* Add AGENTS.md guide for AI coding agents * Pin AGENTS.md duplicated values in test_docs_consistency * Remove unnecessary comment * Align with open source policy requirements * Move confidential info to things to avoid and add PII * Centralize regen tool pins in pyproject.toml regen group * Note current API version may differ from v0.4 in AGENTS.md
1 parent 5be1eb3 commit e184d04

6 files changed

Lines changed: 704 additions & 20 deletions

File tree

.github/workflows/generated.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,18 @@ jobs:
1919
with:
2020
persist-credentials: false
2121
- uses: ./.github/actions/setup-uv
22+
- run: uv sync --group regen
2223
- name: Prepare spec
2324
run: |
2425
set -euo pipefail
2526
if [[ -f openapi-overlay.yaml ]]; then
26-
uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
27+
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
2728
else
2829
cp openapi.json /tmp/patched-spec.json
2930
fi
3031
- name: Regenerate client
3132
run: |
32-
uvx openapi-python-client==0.28.3 generate \
33+
uv run openapi-python-client generate \
3334
--path /tmp/patched-spec.json \
3435
--meta none \
3536
--config openapi-python-client-config.yaml \

AGENTS.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# AGENTS.md
2+
3+
Instructions for AI agents working in this repository. Humans should read [`CONTRIBUTING.md`](CONTRIBUTING.md) first; this file restates the parts that are easy to violate.
4+
5+
## What this is
6+
7+
`ionq-core` is a typed, sync+async Python REST client for the [IonQ Cloud Platform API](https://api.ionq.co/v0.4). Most of `ionq_core/` is **generated** from `openapi.json` via `openapi-python-client`; a small **hand-written** layer at the package root adds retries, hooks, pagination, polling, sessions, structured exceptions, and native-gate unitaries. Apache-2.0, published to PyPI as `ionq-core` (see `pyproject.toml` `[project] version` and `classifiers` for current release status). Most end users should pick a higher-level wrapper (`qiskit-ionq`, `cirq-ionq`, `pennylane-ionq`, CUDA-Q, qbraid) — `ionq-core` is the wire-level building block those SDKs sit on.
8+
9+
## Setup
10+
11+
```sh
12+
uv sync # canonical; uv.lock is committed and CI runs UV_FROZEN=true
13+
pre-commit install
14+
```
15+
16+
`uv` is required. Don't use `pip` / `poetry` for dev workflows — they bypass the lockfile.
17+
18+
## Run
19+
20+
```sh
21+
uv run pytest # unit tests; 100% branch coverage gate on hand-written code
22+
uv run ruff check
23+
uv run ruff format --check # drop --check to apply
24+
uv run ty check ionq_core/
25+
uvx pre-commit run --all-files
26+
27+
# Integration tests hit the real IonQ API. Deselected by default; weekly in CI.
28+
export IONQ_API_KEY=...
29+
uv run pytest -m integration --no-cov
30+
```
31+
32+
`pyproject.toml` is the source of truth for these invocations. Tests treat warnings as errors and use `xfail_strict=True`.
33+
34+
## File boundary — the most important rule
35+
36+
`ionq_core/` has two layers:
37+
38+
- **Generated** — overwritten on every regeneration. The set is enumerated in [`.gitattributes`](.gitattributes) (`linguist-generated=true` lines) and mirrored in `pyproject.toml`'s `ruff.extend-exclude` + `coverage.run.omit`; `tests/test_docs_consistency.py` keeps the three lists aligned. The one exception is `ionq_core/__init__.py`, which is in `.gitattributes` only — its content is rendered from [`custom-templates/package_init.py.jinja`](custom-templates/package_init.py.jinja) but the rendered output is still linted and coverage-checked.
39+
- **Hand-written** — everything else under `ionq_core/`. Extend, fix bugs, add tests.
40+
41+
To check whether a file is generated, look at `.gitattributes`:
42+
43+
```sh
44+
grep -E '^ionq_core/' .gitattributes
45+
```
46+
47+
When you hit a bug in generated code:
48+
- **API surface** (endpoint, schema): upstream spec issue. File a bug; don't patch the file.
49+
- **Local schema fix** (e.g. tightening a type): add an action to `openapi-overlay.yaml` and regenerate.
50+
- **Generator-shape fix**: adjust `openapi-python-client-config.yaml` post-hooks or `custom-templates/`.
51+
52+
## Regenerating the client
53+
54+
Run exactly what's in [`CONTRIBUTING.md`](CONTRIBUTING.md) and mirrored in [`.github/workflows/generated.yml`](.github/workflows/generated.yml):
55+
56+
```sh
57+
uv sync --group regen
58+
# If v0.4 isn't found, search for the latest API version.
59+
curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json
60+
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
61+
uv run openapi-python-client generate \
62+
--path /tmp/patched-spec.json --meta none \
63+
--config openapi-python-client-config.yaml \
64+
--custom-template-path custom-templates \
65+
--output-path ionq_core --overwrite
66+
```
67+
68+
Commit regenerated files in the same PR as the spec/template/overlay change that produced them.
69+
70+
## Calling generated endpoints
71+
72+
Every endpoint module exposes four callables: `sync`, `sync_detailed`, `asyncio`, `asyncio_detailed`. The `_detailed` variants return `Response[T]` (status + headers + parsed); the others return only the parsed body, or `None` on undocumented status when `raise_on_unexpected_status=False`.
73+
74+
**Path params come first (positional or keyword). `client=`, `body=`, and all query params are keyword-only.**
75+
76+
```python
77+
from ionq_core import IonQClient
78+
from ionq_core.api.default import create_job, get_job, get_compiled_file, get_jobs
79+
from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload
80+
81+
client = IonQClient() # reads IONQ_API_KEY
82+
get_job.sync(uuid, client=client) # one path param
83+
get_compiled_file.sync(uuid, lang, client=client) # multiple path params
84+
get_jobs.sync(client=client, status="completed", limit=10) # query only
85+
create_job.sync(client=client, body=payload) # body only
86+
```
87+
88+
Use `next_=` (trailing underscore) for the cursor pagination kwarg — Python keyword collision. The `iter_jobs` / `aiter_jobs` / `iter_session_jobs` / `aiter_session_jobs` helpers handle paging for you.
89+
90+
`UNSET` (sentinel from `ionq_core.types`) means "field omitted"; `None` serializes as JSON `null`. `to_dict()` skips `UNSET` and emits `null` for `None`. Don't conflate.
91+
92+
Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wire header is `Authorization: apiKey {token}`. Don't change this.
93+
94+
## Hand-written conventions
95+
96+
- Every `.py` carries an SPDX header (`# SPDX-FileCopyrightText: <year> IonQ, Inc.` + `Apache-2.0`); generated files also carry `# @generated`. The year must be **uniform across the whole package**`tests/test_docs_consistency.py` fails CI otherwise. At the year boundary, bump every hand-written file to match (the generator post-hook does the rest).
97+
- Public API in each hand-written module is declared via `__all__` at the top; `ionq_core/__init__.py` re-exports those.
98+
- Type-checked by `ty` against Python 3.11. Ruff: `target-version = "py311"`, `line-length = 120`, `select = E, F, I, UP, B, SIM, RUF`.
99+
- 100% branch coverage on hand-written code (`--cov-fail-under=100`); generated paths are in `coverage.run.omit`. New conditional branches need new tests.
100+
- Test fixtures live in [`tests/conftest.py`](tests/conftest.py): `client` (unauth) and `auth_client` (token `"test-api-key"`, `prefix="apiKey"`), both pointing at `https://test.invalid/v0.4`. Use them; don't construct clients ad hoc.
101+
- Mock HTTP with `httpx_mock` from `pytest-httpx`. Don't introduce `responses`, `requests-mock`, or VCR.
102+
- Integration tests are marked `pytest.mark.integration` and live in `tests/integration/`. Use the `track_job` fixture so the autouse `cleanup_jobs` fixture deletes anything you create.
103+
- `gates.py` is intentionally NumPy-free (`cmath`, `math`, nested tuples). Keep it that way.
104+
105+
## Drift sentinels — single edits that fan out
106+
107+
Several values are pinned in multiple files (Python floor, generator/overlay version pins, API base URL, the generated-path set, numeric defaults that appear in both code and docstrings). [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py) is the canonical list of these alignments — when it fails, read the failing assertion to find the peers and update every one in the same PR. Treat that test file as the source of truth; it grows as new pinned values are added.
108+
109+
## CI
110+
111+
Workflows live in [`.github/workflows/`](.github/workflows/)`ls` it for the current set; each file's `on:` block documents its own triggers. Four have non-obvious behavior worth knowing about:
112+
113+
- **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This is what catches hand-edits to generated files.
114+
- **`integration.yml`** is on a weekly cron and `workflow_dispatch` only — it does not run per PR, so don't rely on it for fast feedback.
115+
- **`spec-drift.yml`** opens or updates a `spec-drift`-labeled issue when upstream `openapi.json` diverges from the vendored copy.
116+
- **`release.yml`** triggers on `v*` tags only and refuses mismatched tag/version pairs or republishing existing PyPI versions.
117+
118+
When authoring a new workflow, use the local [`.github/actions/setup-uv`](.github/actions/setup-uv) composite action rather than `astral-sh/setup-uv` directly, for consistency with the existing matrix.
119+
120+
## PR and release conventions
121+
122+
- Branch off `main`. CODEOWNERS is `@ionq/developer-tools`.
123+
- PR titles become release-notes lines (`gh release create --generate-notes`). Imperative mood, user-facing, no leading ticket number.
124+
- User-visible changes go under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
125+
- Release: bump `pyproject.toml` `[project] version`, promote `[Unreleased]``[X.Y.Z]` in `CHANGELOG.md`, tag `vX.Y.Z`. `release.yml` rejects mismatched tag/version pairs and refuses to republish an existing PyPI version.
126+
127+
## Things to avoid (and what to do instead)
128+
129+
- **Including IonQ confidential information** in any committed artifact — code, comments, commit messages, branch names, PR titles/bodies, test fixtures, or docstrings → scrub before pushing; the repo is public (Apache-2.0 on PyPI) and a leak can't be cleanly undone. Confidential covers proprietary algorithms, trade secrets, internal project codenames, internal file paths, server names, IP addresses, API keys, passwords, non-public experimental data, sensitive customer information, PII, and internal-only comments or documentation.
130+
- **Editing generated files by hand** → fix the spec, the overlay, the post-hooks, or the template, then regenerate. CI's `generated.yml` will catch it otherwise.
131+
- **Adding a dependency with `pip install`**`uv add <pkg>` (or edit `pyproject.toml` and `uv lock`). Confirm the dependency's license before adding: MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause are pre-approved.
132+
- **`Bearer` token examples / `requests` / `aiohttp`** in docs or tests → the library is `httpx`-only and the auth prefix is `apiKey`.
133+
- **Dropping the SPDX header or `# @generated` marker** on regenerated files → if a post-hook regression made this happen, fix `openapi-python-client-config.yaml` rather than re-adding by hand.
134+
- **Lowering the Python floor in one file** → run the local checks in the "Run" section; `tests/test_docs_consistency.py` will list every peer that needs updating in the same commit.
135+
- **Adding NumPy or any new runtime dependency** to `gates.py` → keep it pure-Python.
136+
137+
## Where to look first
138+
139+
- Quick start: [`README.md`](README.md) (Bell-state on the simulator).
140+
- Endpoint inventory: `python -c "import json,sys; s=json.load(open('openapi.json')); [print(m.upper(), p) for p,ms in s['paths'].items() for m in ms if m in 'get post put delete patch']"`
141+
- Hand-written entry points: [`ionq_core/ionq_client.py`](ionq_core/ionq_client.py) (the `IonQClient` factory) and [`ionq_core/extensions.py`](ionq_core/extensions.py) (downstream-SDK API).
142+
- Drift checks: [`tests/test_docs_consistency.py`](tests/test_docs_consistency.py).
143+
- Integration smoke test (full job lifecycle): [`tests/integration/test_simulator_job.py`](tests/integration/test_simulator_job.py).

CONTRIBUTING.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,16 @@ CI runs them on a weekly schedule via the [`integration`](.github/workflows/inte
6262
To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generated files, run:
6363

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

6768
if [ -f openapi-overlay.yaml ]; then
68-
uvx oas-patch==0.6.0 overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
69+
uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
6970
else
7071
cp openapi.json /tmp/patched-spec.json
7172
fi
7273

73-
uvx openapi-python-client==0.28.3 generate \
74+
uv run openapi-python-client generate \
7475
--path /tmp/patched-spec.json \
7576
--meta none \
7677
--config openapi-python-client-config.yaml \

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ dev = [
4545
"ty",
4646
"pdoc>=16",
4747
]
48+
regen = [
49+
"oas-patch==0.6.0",
50+
"openapi-python-client==0.28.3",
51+
]
4852

4953
[build-system]
5054
requires = ["hatchling"]

tests/test_docs_consistency.py

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text())
1919
GITATTRIBUTES = (ROOT / ".gitattributes").read_text()
2020
CONTRIB = (ROOT / "CONTRIBUTING.md").read_text()
21-
GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text()
22-
SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text()
21+
AGENTS = (ROOT / "AGENTS.md").read_text()
2322

2423

2524
def _normalize(path: str) -> str:
@@ -43,12 +42,6 @@ def _ci_python_versions() -> list[str]:
4342
return re.findall(r'"(\d+\.\d+)"', m.group(1))
4443

4544

46-
def _pin(text: str, package: str) -> str:
47-
m = re.search(rf"{re.escape(package)}==(\S+)", text)
48-
assert m, f"{package} pin not found"
49-
return m.group(1)
50-
51-
5245
@pytest.mark.parametrize(
5346
"needle",
5447
[
@@ -118,14 +111,6 @@ def test_gitattributes_covers_ruff_paths_plus_init():
118111
assert gitattr == ruff | {"ionq_core/__init__.py"}
119112

120113

121-
def test_openapi_python_client_versions_match():
122-
assert _pin(CONTRIB, "openapi-python-client") == _pin(GENERATED_WF, "openapi-python-client")
123-
124-
125-
def test_oas_patch_versions_match():
126-
assert _pin(CONTRIB, "oas-patch") == _pin(GENERATED_WF, "oas-patch")
127-
128-
129114
def test_spec_path_matches_default_base_url():
130115
# Without this, a DEFAULT_BASE_URL bump leaves CONTRIBUTING.md pointing at a stale endpoint.
131116
spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs"
@@ -147,3 +132,46 @@ def test_single_spdx_year_across_package():
147132
if m:
148133
years.add(m.group(1))
149134
assert len(years) == 1, f"expected exactly one SPDX year, found: {years}"
135+
136+
137+
def test_spec_path_in_agents_md():
138+
"""The api-docs path quoted in AGENTS.md tracks DEFAULT_BASE_URL."""
139+
spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs"
140+
assert spec_path in AGENTS
141+
142+
143+
@pytest.mark.parametrize(
144+
"needle",
145+
[
146+
f"Python {_python_floor()}",
147+
"py" + _python_floor().replace(".", ""),
148+
],
149+
)
150+
def test_python_floor_in_agents_md(needle):
151+
"""Both the prose 'Python X.Y' and the ruff/ty 'pyXY' form appear in AGENTS.md."""
152+
assert needle in AGENTS, f"{needle!r} missing from AGENTS.md"
153+
154+
155+
def test_coverage_threshold_in_agents_md():
156+
"""--cov-fail-under=N in AGENTS.md matches pytest addopts."""
157+
addopts = PYPROJECT["tool"]["pytest"]["ini_options"]["addopts"]
158+
m = re.search(r"--cov-fail-under=\d+", addopts)
159+
assert m, f"--cov-fail-under not in pytest addopts: {addopts!r}"
160+
assert m.group(0) in AGENTS
161+
162+
163+
def test_ruff_line_length_in_agents_md():
164+
assert f"line-length = {PYPROJECT['tool']['ruff']['line-length']}" in AGENTS
165+
166+
167+
def test_ruff_select_in_agents_md():
168+
"""Ruff rule list in AGENTS.md matches pyproject (order-sensitive)."""
169+
rules = ", ".join(PYPROJECT["tool"]["ruff"]["lint"]["select"])
170+
assert rules in AGENTS, f"ruff select rules {rules!r} not in AGENTS.md"
171+
172+
173+
def test_auth_header_in_agents_md():
174+
"""The wire-header phrasing in AGENTS.md matches _AUTH_HEADER + _AUTH_PREFIX."""
175+
from ionq_core.ionq_client import _AUTH_HEADER, _AUTH_PREFIX
176+
177+
assert f"{_AUTH_HEADER}: {_AUTH_PREFIX} " in AGENTS

0 commit comments

Comments
 (0)