From a5155ce6c0e3a775d393b562249cd88daeb1cf6c Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:05:03 -0700 Subject: [PATCH 01/10] Drop PVR from SECURITY.md; route reports to security@ionq.co only Private vulnerability reporting is disabled on this repo, so the previous "preferred" link rendered a dead form for external reporters. The IonQ security team monitors security@ionq.co; make that the only documented channel and stop pointing reporters at a route that GitHub does not forward to the team's inbox. --- SECURITY.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4668486..93b8b73 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,7 @@ **Do not open a public GitHub issue for security vulnerabilities.** -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]`. +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. From 6b4625736e69a18f8764c4822463c06c0c0aec88 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:07:10 -0700 Subject: [PATCH 02/10] Fix stale "channels above" plural in SECURITY.md safe-harbor After dropping PVR there is only one reporting channel; update the safe-harbor paragraph to say "the email above" instead. --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 93b8b73..e9e4c49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ When conducting security research consistent with this policy, we consider your - 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. +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 email 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. From 242f7069ca269d5239b0c5a5e38c9d853335b6d3 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:13:52 -0700 Subject: [PATCH 03/10] Drop low-value pins from test_docs_consistency Apply the philosophy "pin where drift is likely AND bad": - test_retryable_status_codes_match_runtime: tautological self-pin; the set is checked against a copy of itself, no second source of truth. - test_session_example_backend_consistent: stylistic check that all SessionManager examples in session.py share one backend literal. Pure consistency, no runtime correctness link, easily caught in review. - test_pyproject_description_canonical / _init_module_docstring_canonical / _readme_tagline_canonical: the package description rarely changes, and divergence across pyproject / __init__.py / README is benign and reviewer-visible. Three pins for a once-set string isn't worth the maintenance overhead. --- tests/test_docs_consistency.py | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index c22f25f..190d42e 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -9,23 +9,17 @@ import pytest from ionq_core import extensions, polling -from ionq_core._transport import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES +from ionq_core._transport import DEFAULT_MAX_RETRIES 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: @@ -55,10 +49,6 @@ def _pin(text: str, package: str) -> str: 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", [ @@ -86,11 +76,6 @@ 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()) @@ -118,20 +103,6 @@ def test_classifiers_match_ci_matrix(): 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"]} From 826d90f229259d68761ff097308368151ad1cace Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:17:28 -0700 Subject: [PATCH 04/10] Pin spec server path, not full URL, in base-URL drift test Previously test_default_base_url_matches_spec_servers compared the full URL of openapi.json's first server entry against DEFAULT_BASE_URL, which forced the spec to come from the production host. Regenerating the client from staging (different host, same /v0.4 path) tripped the test even though the API-version contract was identical. The version path is the actual safety property the test cares about; the host is just the environment, and the runtime base URL is overridable on IonQClient anyway. Compare urlparse(...).path on both sides so staging regenerations pass while a /v0.4 -> /v0.5 bump still fails until DEFAULT_BASE_URL is updated. --- tests/test_docs_consistency.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 190d42e..83a65d2 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -139,8 +139,10 @@ def test_spec_path_matches_default_base_url(): def test_default_base_url_matches_spec_servers(): + # Compare paths, not full URLs, so a staging-fetched spec + # (different host, same /vX.Y path) still satisfies the version pin. spec = json.loads((ROOT / "openapi.json").read_text()) - assert spec["servers"][0]["url"] == DEFAULT_BASE_URL + assert urlparse(spec["servers"][0]["url"]).path == urlparse(DEFAULT_BASE_URL).path def test_single_spdx_year_across_package(): From 8dca1b77d8e6a508774d676a189b45047da26c86 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:20:25 -0700 Subject: [PATCH 05/10] Drop test_default_base_url_matches_spec_servers as tautological The committed openapi.json's servers[0] path is whatever URL the spec was fetched from. CONTRIBUTING.md hardcodes that fetch URL, and test_spec_path_matches_default_base_url already pins DEFAULT_BASE_URL.path into CONTRIBUTING.md and spec-drift.yml. Under the documented regeneration workflow the spec path equals DEFAULT_BASE_URL.path by construction; the only failure modes left are manual fetches from a wrong version or version bumps without regenerating, both of which code review catches. --- tests/test_docs_consistency.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 83a65d2..060076c 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,6 +1,5 @@ """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 @@ -138,13 +137,6 @@ def test_spec_path_matches_default_base_url(): assert spec_path in SPEC_DRIFT_WF -def test_default_base_url_matches_spec_servers(): - # Compare paths, not full URLs, so a staging-fetched spec - # (different host, same /vX.Y path) still satisfies the version pin. - spec = json.loads((ROOT / "openapi.json").read_text()) - assert urlparse(spec["servers"][0]["url"]).path == urlparse(DEFAULT_BASE_URL).path - - 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 From cbe9d903c62218dd2ca6ff49487c5c81a6b0505d Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:35:30 -0700 Subject: [PATCH 06/10] Pin pyproject version to a CHANGELOG.md entry Likely to drift (every release) and bad if it does (shipped artifact's version disagrees with the changelog readers consult to know what's in it). The release.yml verify-version step already pins the git tag to pyproject; this adds the missing CHANGELOG side of that triangle. --- tests/test_docs_consistency.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 060076c..4e37682 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -17,6 +17,7 @@ PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) GITATTRIBUTES = (ROOT / ".gitattributes").read_text() CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() +CHANGELOG = (ROOT / "CHANGELOG.md").read_text() GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() @@ -102,6 +103,11 @@ def test_classifiers_match_ci_matrix(): assert sorted(_ci_python_versions()) == classifiers, f"matrix={_ci_python_versions()} classifiers={classifiers}" +def test_pyproject_version_in_changelog(): + version = PYPROJECT["project"]["version"] + assert f"## [{version}]" in CHANGELOG, f"pyproject version {version} missing from CHANGELOG.md" + + 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"]} From 785948a8feaea69f4264ad7eb73f58c7f1f835af Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:39:05 -0700 Subject: [PATCH 07/10] Drop test_pyproject_version_in_changelog On honest reflection neither axis of the philosophy holds: release prep tightly couples a pyproject bump with the CHANGELOG rename so drift is unlikely, and "gh release create --generate-notes" already gives users a fallback view of what changed if CHANGELOG.md ever goes stale. Quality erosion at best, not the case-1 "likely AND bad" the test was added under. --- tests/test_docs_consistency.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 4e37682..060076c 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -17,7 +17,6 @@ PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) GITATTRIBUTES = (ROOT / ".gitattributes").read_text() CONTRIB = (ROOT / "CONTRIBUTING.md").read_text() -CHANGELOG = (ROOT / "CHANGELOG.md").read_text() GENERATED_WF = (ROOT / ".github" / "workflows" / "generated.yml").read_text() SPEC_DRIFT_WF = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() @@ -103,11 +102,6 @@ def test_classifiers_match_ci_matrix(): assert sorted(_ci_python_versions()) == classifiers, f"matrix={_ci_python_versions()} classifiers={classifiers}" -def test_pyproject_version_in_changelog(): - version = PYPROJECT["project"]["version"] - assert f"## [{version}]" in CHANGELOG, f"pyproject version {version} missing from CHANGELOG.md" - - 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"]} From d8f25ccceb55f669b7b48e128f65b3e3f91fe807 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:59:55 -0700 Subject: [PATCH 08/10] Audit test_docs_consistency - Drop static (429, 500, 502, 503, "520-529") pins from test_client_extension_docstring_pins. They were hardcoded copies not derived from RETRYABLE_STATUS_CODES, so they inverted the drift the test claims to catch: additions to the constant go unnoticed, and docstring reformats fail spuriously. - Add test_spec_servers_path_in_docs to close the docs <-> spec edge of the (DEFAULT_BASE_URL, docs, spec) triangle. - Collapse multi-line WHY comments on four tests to one line. --- tests/test_docs_consistency.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 060076c..3b6dc16 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -1,5 +1,6 @@ """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 @@ -51,8 +52,6 @@ def _pin(text: str, package: str) -> str: @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", ], @@ -109,8 +108,7 @@ def test_ruff_excludes_match_coverage_omits(): 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. + # __init__.py is template-generated but the template is hand-edited; in ruff/coverage scope, but still linguist-generated. gitattr = { _normalize(line.split()[0]) for line in GITATTRIBUTES.splitlines() @@ -129,19 +127,22 @@ def test_oas_patch_versions_match(): 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. + # Without this, a DEFAULT_BASE_URL bump leaves spec-drift.yml curling 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_spec_servers_path_in_docs(): + # Catches a stale openapi.json: code/docs bumped without regen, or fetched from the wrong version. + spec = json.loads((ROOT / "openapi.json").read_text()) + spec_path = urlparse(spec["servers"][0]["url"]).path + assert f"{spec_path}/api-docs" in CONTRIB + assert f"{spec_path}/api-docs" in SPEC_DRIFT_WF + + 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. - """ + """Generated files get the year via post-hook; hand-written files must be bumped to match at year boundaries.""" years = set() for py in (ROOT / "ionq_core").rglob("*.py"): m = re.match(r"# SPDX-FileCopyrightText: (\d{4}) IonQ, Inc\.", py.read_text()) From e77af11892e53d8dbb5ea7b97d5500fb9fec1c51 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:01:53 -0700 Subject: [PATCH 09/10] Trim test_gitattributes comment to fit 120-char ruff line limit --- tests/test_docs_consistency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 3b6dc16..c75ae5b 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -108,7 +108,7 @@ def test_ruff_excludes_match_coverage_omits(): def test_gitattributes_covers_ruff_paths_plus_init(): - # __init__.py is template-generated but the template is hand-edited; in ruff/coverage scope, but still linguist-generated. + # __init__.py: hand-edited template, generated output; in ruff/coverage scope, marked linguist-generated. gitattr = { _normalize(line.split()[0]) for line in GITATTRIBUTES.splitlines() From 7f5d9afec993408c7d917396d3f323c784baeac0 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:07:12 -0700 Subject: [PATCH 10/10] Trigger CodeQL on PR #31 after enabling default setup