From eda1a81251f490f54c430a8fe427e97bf28e4b5a Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:11:17 -0400 Subject: [PATCH 1/3] Simplify hand-written modules and tests Result of a per-module simplification review (one reviewer per module/test pair, full gates enforced throughout): net -220 lines, behavior unchanged. - polling: fold _check_terminal's redundant terminal-status guard into its return (equivalent because "failed" is itself in _TERMINAL) - session: build the session settings once in __init__ instead of caching three single-use attributes for a lazy _build_settings call - extensions: async protocol docstrings now cross-reference their sync twins; drop the incorrect claim that on_response bodies are pre-read (the transport only reads bodies for >=400 responses, which raise) - transport/exceptions/gates/ionq_client: trim docstring text that was duplicated verbatim elsewhere on the same rendered pdoc page - tests: delete tests whose setup and assertions are strict subsets of other tests, merge the async counting stream into the sync one via dual inheritance, replace test_api's 28-line inline job payload with conftest's make_job_json, fold five AGENTS.md docs pins into one parametrized test - _url, pagination, conftest, and all integration tests: reviewed, already minimal All security behavior and its tests are unchanged: path-parameter rejection, Retry-After clamp, error-body read cap, verify_ssl plumbing, pagination cursor guard, credential repr hygiene, no POST retries. 273 tests pass with 100% branch coverage; ruff, ruff format, and ty are clean; regenerating the client produces no diff. --- ionq_core/_transport.py | 5 --- ionq_core/exceptions.py | 4 +- ionq_core/extensions.py | 15 ++----- ionq_core/gates.py | 6 --- ionq_core/ionq_client.py | 4 -- ionq_core/polling.py | 7 +--- ionq_core/session.py | 32 +++++--------- tests/test_api.py | 32 +------------- tests/test_docs_consistency.py | 37 ++++------------ tests/test_exceptions.py | 16 +------ tests/test_extensions.py | 77 +--------------------------------- tests/test_ionq_client.py | 3 -- tests/test_polling.py | 4 -- tests/test_session.py | 22 ---------- tests/test_transport.py | 26 ++---------- 15 files changed, 35 insertions(+), 255 deletions(-) diff --git a/ionq_core/_transport.py b/ionq_core/_transport.py index b3a8bca..85b8a3d 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -9,11 +9,6 @@ 500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5, max 60s); POST is never retried because the API has no idempotency keys, so a replay after an ambiguous 5xx could duplicate billable work. - -Error handling bounds what it trusts from the server: at most -`MAX_ERROR_BODY_BYTES` decoded bytes of an error body are read, and -``Retry-After`` is clamped to `MAX_RETRY_AFTER` seconds (non-finite values are -discarded) before being exposed on `RateLimitError.retry_after`. """ import json diff --git a/ionq_core/exceptions.py b/ionq_core/exceptions.py index 5136971..747731a 100644 --- a/ionq_core/exceptions.py +++ b/ionq_core/exceptions.py @@ -184,9 +184,7 @@ def raise_for_status( ) -> None: """Raise an appropriate `APIError` subclass for an HTTP error status. - Does nothing for status codes below 400. For 4xx codes, raises the - specific subclass (e.g. `AuthenticationError` for 401). For 5xx codes - or unrecognized 4xx codes, raises `ServerError` or `APIError` respectively. + Does nothing for status codes below 400. Args: status_code: The HTTP status code. diff --git a/ionq_core/extensions.py b/ionq_core/extensions.py index 6babc9e..14139a4 100644 --- a/ionq_core/extensions.py +++ b/ionq_core/extensions.py @@ -67,7 +67,7 @@ def on_response(self, request: httpx.Request, response: httpx.Response) -> None: Args: request: The original HTTP request. - response: The HTTP response. The body has already been read. + response: The HTTP response. """ ... @@ -81,20 +81,11 @@ class AsyncEventHook(Protocol): """ async def on_request(self, request: httpx.Request) -> None: - """Called after the request is built but before it is sent. - - Args: - request: The outgoing HTTP request. - """ + """Async counterpart of `EventHook.on_request`.""" ... async def on_response(self, request: httpx.Request, response: httpx.Response) -> None: - """Called after a response is received. - - Args: - request: The original HTTP request. - response: The HTTP response. - """ + """Async counterpart of `EventHook.on_response`.""" ... diff --git a/ionq_core/gates.py b/ionq_core/gates.py index 8ef069a..3d19790 100644 --- a/ionq_core/gates.py +++ b/ionq_core/gates.py @@ -98,12 +98,6 @@ def ms_matrix(phi0: float, phi1: float, angle: float = 0.25) -> Matrix4x4: Returns: A `Matrix4x4` unitary matrix. - - Examples: - ```python - >>> ms_matrix(0, 0) # maximally-entangling MS gate - >>> ms_matrix(0, 0, 0.125) # partial entanglement - ``` """ a = math.pi * angle ca, sa = math.cos(a), math.sin(a) diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index d57a795..f740d28 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -48,10 +48,6 @@ def IonQClient( ) -> AuthenticatedClient: """Create an authenticated IonQ API client. - This is the recommended entry point for using the library. It handles - authentication, retry configuration, User-Agent construction, and transport - setup for both sync and async usage. - Args: api_key: IonQ API key. If not provided, reads the ``IONQ_API_KEY`` environment variable. diff --git a/ionq_core/polling.py b/ionq_core/polling.py index 13e9b52..023aaf7 100644 --- a/ionq_core/polling.py +++ b/ionq_core/polling.py @@ -80,12 +80,9 @@ def __init__(self, job_id: str, failure: object) -> None: def _check_terminal(job: GetJobResponse, raise_on_failure: bool) -> bool: - if job.status not in _TERMINAL: - return False if raise_on_failure and job.status == "failed": - failure = job.failure if not isinstance(job.failure, Unset) else None - raise JobFailedError(job.id, failure) - return True + raise JobFailedError(job.id, None if isinstance(job.failure, Unset) else job.failure) + return job.status in _TERMINAL def wait_for_job( diff --git a/ionq_core/session.py b/ionq_core/session.py index c17f45e..4ccec11 100644 --- a/ionq_core/session.py +++ b/ionq_core/session.py @@ -60,13 +60,6 @@ class SessionManager: max_cost: Optional maximum cost in USD for the session. Examples: - Sync context manager: - - ```python - with SessionManager(client, "qpu.aria-1", max_jobs=10) as session: - print(session.session_id) - ``` - Async context manager: ```python @@ -86,10 +79,15 @@ def __init__( ) -> None: self._client = client self._backend = backend - self._max_jobs = max_jobs - self._max_time = max_time - self._max_cost = max_cost self._session_id: str | None = None + kw: dict = {} + if max_jobs is not None: + kw["job_count_limit"] = max_jobs + if max_time is not None: + kw["duration_limit_min"] = max_time + if max_cost is not None: + kw["cost_limit"] = SessionCostLimit(unit="usd", value=max_cost) + self._settings: SessionSettingsRequest | Unset = SessionSettingsRequest(**kw) if kw else UNSET @classmethod def from_id(cls, client: AuthenticatedClient, session_id: str) -> SessionManager: @@ -116,16 +114,6 @@ def session_id(self) -> str | None: """The session ID, or ``None`` if `open` has not been called.""" return self._session_id - def _build_settings(self) -> SessionSettingsRequest | Unset: - kw: dict = {} - if self._max_jobs is not None: - kw["job_count_limit"] = self._max_jobs - if self._max_time is not None: - kw["duration_limit_min"] = self._max_time - if self._max_cost is not None: - kw["cost_limit"] = SessionCostLimit(unit="usd", value=self._max_cost) - return SessionSettingsRequest(**kw) if kw else UNSET - def open(self) -> None: """Create a new session on the configured backend. @@ -134,7 +122,7 @@ def open(self) -> None: """ if self._session_id is not None: raise IonQError("Session already open") - body = CreateSessionRequest(backend=self._backend, settings=self._build_settings()) + body = CreateSessionRequest(backend=self._backend, settings=self._settings) session = create_session.sync(client=self._client, body=body) if session is None: raise IonQError("Failed to create session") @@ -168,7 +156,7 @@ async def async_open(self) -> None: """Async version of `open`.""" if self._session_id is not None: raise IonQError("Session already open") - body = CreateSessionRequest(backend=self._backend, settings=self._build_settings()) + body = CreateSessionRequest(backend=self._backend, settings=self._settings) session = await create_session.asyncio(client=self._client, body=body) if session is None: raise IonQError("Failed to create session") diff --git a/tests/test_api.py b/tests/test_api.py index 02f02d1..536dffa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,7 @@ from ionq_core.models.get_jobs_response import GetJobsResponse from ionq_core.models.job_creation_response import JobCreationResponse from ionq_core.models.whoami import Whoami +from tests.conftest import make_job_json WHOAMI_JSON = {"key_id": "e060759f-4348-4767-a645-8c0301265791", "key_name": "Test Key"} @@ -32,36 +33,7 @@ }, ] -JOBS_JSON = { - "jobs": [ - { - "id": "job-1", - "status": "completed", - "type": "ionq.circuit.v1", - "backend": "simulator", - "dry_run": False, - "submitter_id": "user-1", - "project_id": "proj-1", - "parent_job_id": "parent-1", - "session_id": "sess-1", - "metadata": {}, - "name": "Test", - "submitted_at": "2025-05-28T20:47:05.440Z", - "started_at": "2025-05-28T20:48:00Z", - "completed_at": "2025-05-28T20:49:00Z", - "predicted_wait_time_ms": 5000, - "predicted_execution_duration_ms": 3000, - "execution_duration_ms": 2800, - "shots": 1000, - "failure": {"code": "InternalError", "message": "test"}, - "output": {}, - "settings": {}, - "stats": {}, - "results": {}, - } - ], - "next": "cursor-token", -} +JOBS_JSON = {"jobs": [make_job_json("job-1")], "next": "cursor-token"} class TestGetWhoami: diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index b90d518..dafe012 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -11,7 +11,7 @@ from ionq_core import extensions, polling from ionq_core._transport import DEFAULT_MAX_RETRIES, MAX_RETRY_AFTER from ionq_core.exceptions import RateLimitError -from ionq_core.ionq_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT +from ionq_core.ionq_client import _AUTH_HEADER, _AUTH_PREFIX, 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 @@ -140,21 +140,19 @@ def test_single_spdx_year_across_package(): assert len(years) == 1, f"expected exactly one SPDX year, found: {years}" -def test_spec_path_in_agents_md(): - """The api-docs path quoted in AGENTS.md tracks DEFAULT_BASE_URL.""" - spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs" - assert spec_path in AGENTS - - @pytest.mark.parametrize( "needle", [ - f"Python {_python_floor()}", - "py" + _python_floor().replace(".", ""), + f"Python {_python_floor()}", # prose floor + "py" + _python_floor().replace(".", ""), # ruff/ty target form of the floor + f"line-length = {PYPROJECT['tool']['ruff']['line-length']}", + ", ".join(PYPROJECT["tool"]["ruff"]["lint"]["select"]), # rule list, order-sensitive + f"{_AUTH_HEADER}: {_AUTH_PREFIX} ", # wire auth header phrasing + f"{urlparse(DEFAULT_BASE_URL).path}/api-docs", # api-docs path tracks DEFAULT_BASE_URL ], ) -def test_python_floor_in_agents_md(needle): - """Both the prose 'Python X.Y' and the ruff/ty 'pyXY' form appear in AGENTS.md.""" +def test_agents_md_pins(needle): + """Values quoted in AGENTS.md that must track code/config.""" assert needle in AGENTS, f"{needle!r} missing from AGENTS.md" @@ -164,20 +162,3 @@ def test_coverage_threshold_in_agents_md(): m = re.search(r"--cov-fail-under=\d+", addopts) assert m, f"--cov-fail-under not in pytest addopts: {addopts!r}" assert m.group(0) in AGENTS - - -def test_ruff_line_length_in_agents_md(): - assert f"line-length = {PYPROJECT['tool']['ruff']['line-length']}" in AGENTS - - -def test_ruff_select_in_agents_md(): - """Ruff rule list in AGENTS.md matches pyproject (order-sensitive).""" - rules = ", ".join(PYPROJECT["tool"]["ruff"]["lint"]["select"]) - assert rules in AGENTS, f"ruff select rules {rules!r} not in AGENTS.md" - - -def test_auth_header_in_agents_md(): - """The wire-header phrasing in AGENTS.md matches _AUTH_HEADER + _AUTH_PREFIX.""" - from ionq_core.ionq_client import _AUTH_HEADER, _AUTH_PREFIX - - assert f"{_AUTH_HEADER}: {_AUTH_PREFIX} " in AGENTS diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index f8ece26..7ab0c6f 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -42,8 +42,9 @@ def test_400_preserves_body(self): def test_429_preserves_retry_after(self): with pytest.raises(RateLimitError) as exc_info: - raise_for_status(429, retry_after=30.0) + raise_for_status(429, retry_after=30.0, request_id="req-789") assert exc_info.value.retry_after == 30.0 + assert exc_info.value.request_id == "req-789" def test_unknown_4xx_raises_api_error(self): with pytest.raises(APIError) as exc_info: @@ -61,22 +62,9 @@ def test_api_error_has_status_code(self): assert exc.status_code == 500 assert exc.body == {"error": "oops"} assert str(exc) == "Server error" - - def test_api_error_request_id(self): - exc = APIError(500, request_id="req-123") - assert exc.request_id == "req-123" - - def test_api_error_request_id_default_none(self): - exc = APIError(500) assert exc.request_id is None def test_request_id_on_raise_for_status(self): with pytest.raises(ServerError) as exc_info: raise_for_status(500, request_id="req-456") assert exc_info.value.request_id == "req-456" - - def test_request_id_on_rate_limit(self): - with pytest.raises(RateLimitError) as exc_info: - raise_for_status(429, retry_after=10.0, request_id="req-789") - assert exc_info.value.request_id == "req-789" - assert exc_info.value.retry_after == 10.0 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b48e961..4d320cf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -82,9 +82,6 @@ def test_additional_user_agent_and_extension_both_present(self): assert "custom/2.0" in ua assert "qiskit-ionq/1.1.0" in ua - def test_no_extension_still_works(self): - assert _ua(IonQClient(api_key="key")).startswith("ionq-core/") - def test_async_client_user_agent(self): client = IonQClient(api_key="key", extension=ClientExtension(user_agent_token="cirq-ionq/0.5.0")) assert "cirq-ionq/0.5.0" in client.get_async_httpx_client().headers["User-Agent"] @@ -118,15 +115,8 @@ def test_caller_timeout_beats_extension(self): t = IonQClient(api_key="key", timeout=httpx.Timeout(30.0), extension=ext) assert t.get_httpx_client().timeout.read == 30.0 - def test_no_extension_timeout_uses_explicit(self): - assert IonQClient(api_key="key", timeout=httpx.Timeout(30.0)).get_httpx_client().timeout.read == 30.0 - class TestMaxRetriesPrecedence: - def test_transport_created_with_extension(self): - client = IonQClient(api_key="key", extension=ClientExtension(max_retries=5)) - assert isinstance(client.get_httpx_client()._transport, ErrorRaisingTransport) - def test_transport_created_with_explicit_retries(self): client = IonQClient(api_key="key", max_retries=3) assert isinstance(client.get_httpx_client()._transport, ErrorRaisingTransport) @@ -287,44 +277,16 @@ class BrokenHook: def on_request(self, request): raise RuntimeError("hook failed") - def on_response(self, request, response): - pass - - def on_error(self, request, error): - pass - with pytest.raises(RuntimeError, match="hook failed"): HookTransport(FakeTransport(httpx.Response(200)), (BrokenHook(),), debug=True).handle_request( httpx.Request("GET", _BACKENDS_URL) ) - def test_non_debug_swallows_hook_exception(self): - class BrokenHook: - def on_request(self, request): - raise RuntimeError("hook failed") - - def on_response(self, request, response): - pass - - def on_error(self, request, error): - pass - - result = HookTransport(FakeTransport(httpx.Response(200)), (BrokenHook(),), debug=False).handle_request( - httpx.Request("GET", _BACKENDS_URL) - ) - assert result.status_code == 200 - async def test_async_debug_propagates(self): class BrokenAsyncHook: async def on_request(self, request): raise RuntimeError("async hook failed") - async def on_response(self, request, response): - pass - - async def on_error(self, request, error): - pass - with pytest.raises(RuntimeError, match="async hook failed"): await HookTransport( FakeTransport(httpx.Response(200)), (BrokenAsyncHook(),), debug=True @@ -401,12 +363,6 @@ def mapper(exc): with pytest.raises(DownstreamError, match="mapped"): await transport.handle_async_request(httpx.Request("GET", _BACKENDS_URL)) - async def test_async_mapper_passthrough(self): - transport = HookTransport(RaisingTransport(NotFoundError(404)), error_mapper=lambda exc: exc) - - with pytest.raises(NotFoundError): - await transport.handle_async_request(httpx.Request("GET", _BACKENDS_URL)) - def test_error_mapper_wired_in_transport_chain(self): ext = ClientExtension(error_mapper=lambda exc: exc) transport = IonQClient(api_key="key", extension=ext).get_httpx_client()._transport @@ -479,14 +435,10 @@ def test_set_httpx_client_replaces(self): class TestHookTransportClose: def test_close_delegates(self): - fake = FakeTransport(httpx.Response(200)) - transport = HookTransport(fake, ()) - transport.close() + HookTransport(FakeTransport(httpx.Response(200)), ()).close() async def test_aclose_delegates(self): - fake = FakeTransport(httpx.Response(200)) - transport = HookTransport(fake, ()) - await transport.aclose() + await HookTransport(FakeTransport(httpx.Response(200)), ()).aclose() class TestTransportChainOrder: @@ -509,28 +461,3 @@ def close(self): assert isinstance(transport, OuterTransport) assert isinstance(transport.inner, HookTransport) assert isinstance(transport.inner._transport, ErrorRaisingTransport) - - def test_full_chain_with_error_mapper(self): - hook = RecordingHook() - - class OuterTransport(httpx.BaseTransport): - def __init__(self, inner): - self.inner = inner - - def handle_request(self, request): - return self.inner.handle_request(request) - - def close(self): - self.inner.close() - - ext = ClientExtension( - event_hooks=(hook,), - error_mapper=lambda exc: exc, - transport_wrapper=lambda t: OuterTransport(t), - ) - transport = IonQClient(api_key="key", extension=ext).get_httpx_client()._transport - - assert isinstance(transport, OuterTransport) - assert isinstance(transport.inner, HookTransport) - assert transport.inner._error_mapper is not None - assert isinstance(transport.inner._transport, ErrorRaisingTransport) diff --git a/tests/test_ionq_client.py b/tests/test_ionq_client.py index 98b4174..1104e8b 100644 --- a/tests/test_ionq_client.py +++ b/tests/test_ionq_client.py @@ -61,9 +61,6 @@ def test_default_timeout(self): def test_custom_timeout(self): assert IonQClient(api_key="key", timeout=httpx.Timeout(120.0)).get_httpx_client().timeout.read == 120.0 - def test_error_raising_transport_wired(self): - assert isinstance(IonQClient(api_key="key").get_httpx_client()._transport, ErrorRaisingTransport) - def test_async_client_wired(self): ac = IonQClient(api_key="key").get_async_httpx_client() assert isinstance(ac._transport, ErrorRaisingTransport) diff --git a/tests/test_polling.py b/tests/test_polling.py index d0c34d7..b7a5a8c 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -60,10 +60,6 @@ async def test_raises_on_failure(self, httpx_mock, auth_client): with pytest.raises(JobFailedError, match="j1"): await async_wait_for_job(auth_client, "j1", timeout=5) - async def test_canceled_returns(self, httpx_mock, auth_client): - httpx_mock.add_response(json=make_job_json("j1", "canceled")) - assert (await async_wait_for_job(auth_client, "j1", timeout=5)).status == "canceled" - async def test_none_response_raises(self, httpx_mock, auth_client): httpx_mock.add_response(status_code=500) auth_client.raise_on_unexpected_status = False diff --git a/tests/test_session.py b/tests/test_session.py index 2d0da94..fa64f36 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -91,19 +91,6 @@ def test_queries_session(self, httpx_mock, auth_client): class TestOpenClose: - def test_open_close_outside_context(self, httpx_mock, auth_client): - httpx_mock.add_response(status_code=201, json=_session_json(), method="POST") - httpx_mock.add_response(json=_session_json(active=False), method="POST") - - mgr = SessionManager(auth_client, "qpu.aria-1") - mgr.open() - assert mgr.session_id == "sess-1" - mgr.close() - - reqs = httpx_mock.get_requests() - assert reqs[0].url.path == f"{_API_PATH}/sessions" - assert "/sessions/sess-1/end" in str(reqs[1].url) - def test_open_when_already_open_raises(self, httpx_mock, auth_client): httpx_mock.add_response(status_code=201, json=_session_json(), method="POST") mgr = SessionManager(auth_client, "qpu.aria-1") @@ -155,15 +142,6 @@ async def test_end_called_on_exception(self, httpx_mock, auth_client): class TestAsyncOpenClose: - async def test_async_open_close(self, httpx_mock, auth_client): - httpx_mock.add_response(status_code=201, json=_session_json(), method="POST") - httpx_mock.add_response(json=_session_json(active=False), method="POST") - - mgr = SessionManager(auth_client, "qpu.aria-1") - await mgr.async_open() - assert mgr.session_id == "sess-1" - await mgr.async_close() - async def test_async_open_when_already_open_raises(self, httpx_mock, auth_client): httpx_mock.add_response(status_code=201, json=_session_json(), method="POST") mgr = SessionManager(auth_client, "qpu.aria-1") diff --git a/tests/test_transport.py b/tests/test_transport.py index f31a6ca..a996197 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -105,16 +105,6 @@ def test_401_raises_auth_error(self): with pytest.raises(AuthenticationError): transport.handle_request(_req()) - def test_404_raises_not_found(self): - transport, _ = _wrap([_resp(404)]) - with pytest.raises(NotFoundError): - transport.handle_request(_req()) - - def test_429_raises_rate_limit(self): - transport, _ = _wrap([_resp(429)]) - with pytest.raises(RateLimitError): - transport.handle_request(_req()) - def test_503_raises_server_error(self): transport, _ = _wrap([_resp(503)]) with pytest.raises(ServerError) as exc_info: @@ -242,7 +232,7 @@ def test_retry_after_bounded(self, header, expected): assert exc_info.value.retry_after == expected -class _CountingStream(httpx.SyncByteStream): +class _CountingStream(httpx.SyncByteStream, httpx.AsyncByteStream): """A large streamed body that records how many chunks were consumed.""" def __init__(self, chunk_size=16384, chunks=1000): @@ -255,17 +245,9 @@ def __iter__(self): self.consumed += 1 yield self.chunk - -class _CountingAsyncStream(httpx.AsyncByteStream): - def __init__(self, chunk_size=16384, chunks=1000): - self.chunk = b"x" * chunk_size - self.chunks = chunks - self.consumed = 0 - async def __aiter__(self): - for _ in range(self.chunks): - self.consumed += 1 - yield self.chunk + for chunk in self: + yield chunk class TestErrorBodyCap: @@ -283,7 +265,7 @@ def test_sync_read_stops_at_cap(self): assert len(exc_info.value.body) <= 500 async def test_async_read_stops_at_cap(self): - stream = _CountingAsyncStream() + stream = _CountingStream() transport, _ = _wrap([httpx.Response(400, stream=stream)]) with pytest.raises(BadRequestError) as exc_info: await transport.handle_async_request(_req()) From fb32caf46cc5dbce318c3706da4114029e86bfed Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:40:21 -0400 Subject: [PATCH 2/3] Align error, client, and hook contracts with observed behavior From a five-auditor holistic review of the repository: - Every APIError now carries retry_after; RateLimitError's bespoke __init__ and the dispatcher's special case are gone. The transport already parsed and clamped Retry-After for every status, so the value is no longer discarded for non-429 responses (RFC 9110 allows the header on e.g. 503). - IonQClient(headers=...) merges instead of raising TypeError, cookies now reach the async client too, and the kwargs docstring states exactly which keys do what (httpx_args is reserved). - build_transport creates the TLS context once and shares it across the sync and async transports instead of loading the CA bundle twice per client (~6 ms and one redundant trust store per IonQClient()). - EventHook docs now state that on_response fires only for successes and that the optional on_error hook exists; it was fired by HookTransport but documented nowhere the published docs render. - gates exports Matrix2x2/Matrix4x4, which six docstrings already reference as return types (they were dead links on the docs site). - exceptions.py's hierarchy diagram includes the polling exceptions and names errors.UnexpectedStatus as the one type outside the IonQError tree. Tests: the three fake-transport doubles collapse into one conftest helper; duplicates with named survivors are removed (get_jobs smoke vs pagination, session none-response twins, 401 transport mapping, redundant isinstance checks, an integration list check implied by its neighbor); new tests cover the header merge, async cookies, shared SSL context, retry_after on 503, and the previously untested {"error": ...} message key. The two CONTRIBUTING spec-path pins merge into one strictly stronger test that also pins spec-drift.yml's SPEC_URL to DEFAULT_BASE_URL. --- CHANGELOG.md | 8 ++++++ ionq_core/_transport.py | 8 ++++-- ionq_core/exceptions.py | 30 +++++++++------------ ionq_core/extensions.py | 11 +++++++- ionq_core/gates.py | 2 +- ionq_core/ionq_client.py | 11 ++++++-- tests/conftest.py | 22 ++++++++++++++++ tests/integration/test_async.py | 2 +- tests/integration/test_backends.py | 4 --- tests/test_api.py | 16 +----------- tests/test_docs_consistency.py | 18 ++++++------- tests/test_exceptions.py | 7 +++++ tests/test_extensions.py | 38 ++++++--------------------- tests/test_ionq_client.py | 12 +++++++++ tests/test_pagination.py | 13 --------- tests/test_transport.py | 42 ++++++------------------------ 16 files changed, 113 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a185b9..41ebb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `Matrix2x2` and `Matrix4x4` are now exported from `ionq_core.gates`; they were already documented as the return types of the gate unitaries. - `QctrlQaoaJobCreationPayload` and `QctrlQaoaJobInput` for submitting Q-CTRL QAOA maxcut combinatorial-optimization jobs via `create_job`. The `create_job` body union now also accepts `QctrlQaoaJobCreationPayload`. - `cost_model` optional field on `BaseJob`, `GetCircuitJobResponse`, and `GetJobResponse`, typed as `ApiCostModel` (`"QCT"` or `"2QGE_operations"`). - `clone_job` endpoint (`POST /jobs/{UUID}/clone`) and its `CloneJobPayload` model for resubmitting an existing job with optional overrides. @@ -28,10 +29,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- Every `APIError` now carries `retry_after` (parsed and clamped from the `Retry-After` header). Previously only `RateLimitError` exposed it and the value was discarded for other statuses such as 503, where RFC 9110 also allows the header. - POST requests are no longer retried automatically by the default transport. The API has no idempotency-key mechanism, so replaying `create_job` / `create_session` / `end_session` after an ambiguous gateway 5xx could duplicate billable work; idempotent methods retry as before. Callers that want POST retries must supply their own transport and handle deduplication. - `NativeCircuitInput.qubits` and `JsonMultiCircuitInput.qubits` are now `int | Unset` (previously `float | Unset`), matching upstream's tightening to `format: int32, minimum: 1`. `QisCircuitInput.qubits` already had this type locally via the OpenAPI overlay; that overlay action has been removed now that upstream is correct natively. - Regenerated with `openapi-python-client` 0.29.0. Generated models now parse timestamps with the standard library (`datetime.fromisoformat`) instead of `dateutil.parser.isoparse`. +### Fixed + +- `IonQClient(headers=...)` no longer raises `TypeError`; caller headers are merged beneath the extension defaults and the generated `User-Agent`. +- `cookies` passed to `IonQClient` now reach the async client as well (previously the sync client only). +- `IonQClient()` no longer builds the TLS trust store twice; the SSL context is created once and shared by the sync and async transports. + ### Removed - `get_compiled_file` endpoint (`GET /jobs/{UUID}/circuits/{lang}`) and its `GetCompiledFileLang` enum, removed upstream in favor of `get_job_artifact`. Compiled circuits are now fetched as artifacts by id rather than by `lang` (`"native"` / `"qasm3"`). diff --git a/ionq_core/_transport.py b/ionq_core/_transport.py index 85b8a3d..8df637f 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -169,7 +169,11 @@ def build_transport( # POST is deliberately not retryable: without idempotency keys, a replay # after an ambiguous 5xx could duplicate billable jobs. ) + # Build the SSL context once: handing `verify` to each transport would load + # the CA bundle from disk twice per client (create_ssl_context passes an + # ssl.SSLContext through unchanged, so pinned contexts keep their identity). + ctx = httpx.create_ssl_context(verify=verify) return ErrorRaisingTransport( - RetryTransport(transport=httpx.HTTPTransport(verify=verify), retry=retry), - RetryTransport(transport=httpx.AsyncHTTPTransport(verify=verify), retry=retry), + RetryTransport(transport=httpx.HTTPTransport(verify=ctx), retry=retry), + RetryTransport(transport=httpx.AsyncHTTPTransport(verify=ctx), retry=retry), ) diff --git a/ionq_core/exceptions.py b/ionq_core/exceptions.py index 747731a..27001b9 100644 --- a/ionq_core/exceptions.py +++ b/ionq_core/exceptions.py @@ -9,13 +9,15 @@ IonQError +-- APIConnectionError # network / DNS failures | +-- APITimeoutError # request timed out -+-- APIError # HTTP 4xx / 5xx responses ++-- APIError # HTTP 4xx / 5xx responses (carries retry_after) | +-- BadRequestError # 400 | +-- AuthenticationError # 401 | +-- PermissionDeniedError # 403 | +-- NotFoundError # 404 -| +-- RateLimitError # 429 (includes retry_after) +| +-- RateLimitError # 429 | +-- ServerError # 5xx ++-- JobTimeoutError # polling deadline exceeded (ionq_core.polling) ++-- JobFailedError # polled job ended in failure (ionq_core.polling) ``` Example: @@ -50,7 +52,9 @@ class IonQError(Exception): """Base exception for all IonQ errors. Catch this to handle any error raised by the library, including connection - failures, API errors, polling timeouts, and job failures. + failures, API errors, polling timeouts, and job failures. The one + exception outside this tree is ``errors.UnexpectedStatus``, raised only + for undocumented status codes when ``raise_on_unexpected_status`` is set. """ @@ -80,6 +84,8 @@ class APIError(IonQError): or ``None`` if the body could not be read). message: A human-readable error message extracted from the response, or a default ``"HTTP "`` string. + retry_after: Seconds to wait before retrying, from the ``Retry-After`` + header, or ``None`` if the server did not send a usable one. request_id: The ``x-request-id`` header from the response, useful for contacting IonQ support about a specific request. """ @@ -89,11 +95,13 @@ def __init__( status_code: int, body: dict | str | None = None, message: str | None = None, + retry_after: float | None = None, *, request_id: str | None = None, ) -> None: self.status_code = status_code self.body = body + self.retry_after = retry_after self.request_id = request_id self.message = message or f"HTTP {status_code}" super().__init__(self.message) @@ -144,18 +152,6 @@ class RateLimitError(APIError): attribute into an unbounded wait. """ - def __init__( - self, - status_code: int = 429, - body: dict | str | None = None, - message: str | None = None, - retry_after: float | None = None, - *, - request_id: str | None = None, - ) -> None: - super().__init__(status_code, body, message, request_id=request_id) - self.retry_after = retry_after - class ServerError(APIError): """Raised on ``5xx`` server errors. @@ -205,6 +201,4 @@ def raise_for_status( if status_code < 400: return exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ServerError if status_code >= 500 else APIError) - if exc_cls is RateLimitError: - raise RateLimitError(status_code, body, message, retry_after, request_id=request_id) - raise exc_cls(status_code, body, message, request_id=request_id) + raise exc_cls(status_code, body, message, retry_after, request_id=request_id) diff --git a/ionq_core/extensions.py b/ionq_core/extensions.py index 14139a4..bd4bc94 100644 --- a/ionq_core/extensions.py +++ b/ionq_core/extensions.py @@ -50,6 +50,11 @@ class EventHook(Protocol): Implement this protocol and pass instances via `ClientExtension.event_hooks` to receive callbacks on every request. + Hooks may also define an optional ``on_error(request, exc)`` method, + fired before a transport exception is re-raised. It is looked up by name + and deliberately not part of this protocol, so minimal hooks still pass + ``isinstance`` checks. + Hook exceptions are logged and suppressed by default. Set ``debug_hooks=True`` on `ClientExtension` to re-raise them instead. """ @@ -63,7 +68,11 @@ def on_request(self, request: httpx.Request) -> None: ... def on_response(self, request: httpx.Request, response: httpx.Response) -> None: - """Called after a response is received. + """Called after a successful response is received. + + Not called for error responses: the wrapped transport raises an + `IonQError` before this hook fires. Define ``on_error`` to observe + failures. Args: request: The original HTTP request. diff --git a/ionq_core/gates.py b/ionq_core/gates.py index 3d19790..5badd45 100644 --- a/ionq_core/gates.py +++ b/ionq_core/gates.py @@ -28,7 +28,7 @@ ``` """ -__all__ = ["gpi2_matrix", "gpi_matrix", "ms_matrix", "zz_matrix"] +__all__ = ["Matrix2x2", "Matrix4x4", "gpi2_matrix", "gpi_matrix", "ms_matrix", "zz_matrix"] import cmath import math diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index f740d28..2eaa43b 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -64,7 +64,10 @@ def IonQClient( **kwargs: Passed through to `AuthenticatedClient`. ``verify_ssl`` (``True``/``False``, a CA bundle path, or an ``ssl.SSLContext``) is also applied to the underlying httpx transports on both the - sync and async paths. + sync and async paths. ``headers`` are merged beneath the + extension defaults and the generated ``User-Agent``; ``cookies`` + reach both the sync and async clients. ``httpx_args`` is + reserved: the transport slot is owned by `IonQClient`. Returns: An `AuthenticatedClient` configured with retry transport and @@ -134,7 +137,10 @@ def IonQClient( effective_timeout = timeout or ext.timeout or DEFAULT_TIMEOUT effective_retries = next(v for v in (max_retries, ext.max_retries, DEFAULT_MAX_RETRIES) if v is not None) - headers = {**ext.default_headers, "User-Agent": user_agent} + # Caller headers are merged here (extension defaults and the User-Agent + # win) rather than forwarded, which would collide with this dict in + # AuthenticatedClient(**kwargs). + headers = {**(kwargs.pop("headers", None) or {}), **ext.default_headers, "User-Agent": user_agent} # httpx ignores client-level `verify` when a custom transport is supplied, # so the caller's verify_ssl must be plumbed into the transports here. @@ -182,6 +188,7 @@ def IonQClient( httpx.AsyncClient( base_url=base_url, headers={**headers, _AUTH_HEADER: f"{_AUTH_PREFIX} {key}"}, + cookies=kwargs.get("cookies") or {}, timeout=effective_timeout, transport=async_transport, follow_redirects=client._follow_redirects, diff --git a/tests/conftest.py b/tests/conftest.py index 07876a8..f9028aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from urllib.parse import urlparse +import httpx import pytest from ionq_core import AuthenticatedClient, Client @@ -8,6 +9,27 @@ BASE_URL = "https://test.invalid" + urlparse(DEFAULT_BASE_URL).path +class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + """Scripted transport double: returns (or raises) the queued items in order.""" + + def __init__(self, *responses): + self._responses = list(responses) + self.call_count = 0 + + def _next(self): + self.call_count += 1 + item = self._responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + def handle_request(self, request): + return self._next() + + async def handle_async_request(self, request): + return self._next() + + def make_job_json(job_id, status="completed", **overrides): """Minimal valid job dict usable as both BaseJob and GetJobResponse.""" return { diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 7c56a07..89bba5e 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -11,7 +11,7 @@ @pytest.fixture def async_client(api_key): - """Separate client instance - the session-scoped client may not have an async transport.""" + """Separate client instance - `async with` would close the session-scoped client for later tests.""" return IonQClient(api_key=api_key) diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py index b409400..269b522 100644 --- a/tests/integration/test_backends.py +++ b/tests/integration/test_backends.py @@ -15,10 +15,6 @@ def backends(): return get_backends.sync(client=Client(base_url=DEFAULT_BASE_URL)) -def test_list_returns_backends(backends): - assert len(backends) > 0 - - def test_list_has_qpu(backends): assert any("qpu" in b.backend for b in backends) diff --git a/tests/test_api.py b/tests/test_api.py index 536dffa..dd3db8e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,16 +1,14 @@ import pytest from ionq_core.api.backends import get_backends -from ionq_core.api.default import clone_job, create_job, get_job_artifact, get_jobs +from ionq_core.api.default import clone_job, create_job, get_job_artifact from ionq_core.api.whoami import get_whoami from ionq_core.errors import UnexpectedStatus from ionq_core.models.backend import Backend from ionq_core.models.circuit_job_creation_payload import CircuitJobCreationPayload from ionq_core.models.clone_job_payload import CloneJobPayload -from ionq_core.models.get_jobs_response import GetJobsResponse from ionq_core.models.job_creation_response import JobCreationResponse from ionq_core.models.whoami import Whoami -from tests.conftest import make_job_json WHOAMI_JSON = {"key_id": "e060759f-4348-4767-a645-8c0301265791", "key_name": "Test Key"} @@ -33,8 +31,6 @@ }, ] -JOBS_JSON = {"jobs": [make_job_json("job-1")], "next": "cursor-token"} - class TestGetWhoami: def test_sync(self, httpx_mock, auth_client): @@ -67,16 +63,6 @@ def test_sync(self, httpx_mock, client): assert result[1].degraded is True -class TestGetJobs: - def test_sync(self, httpx_mock, auth_client): - httpx_mock.add_response(json=JOBS_JSON) - result = get_jobs.sync(client=auth_client) - assert isinstance(result, GetJobsResponse) - assert len(result.jobs) == 1 - assert result.jobs[0].id == "job-1" - assert result.jobs[0].status == "completed" - - class TestCreateJob: def test_sync(self, httpx_mock, auth_client): httpx_mock.add_response(status_code=201, json={"id": "new-job-id", "status": "submitted", "session_id": None}) diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index dafe012..764b08b 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -117,17 +117,15 @@ def test_gitattributes_covers_ruff_paths_plus_init(): assert gitattr == ruff | {"ionq_core/__init__.py"} -def test_spec_path_matches_default_base_url(): - # Without this, a DEFAULT_BASE_URL bump leaves CONTRIBUTING.md pointing at a stale endpoint. - spec_path = f"{urlparse(DEFAULT_BASE_URL).path}/api-docs" - assert spec_path in CONTRIB - - -def test_spec_servers_path_in_docs(): - # Catches a stale openapi.json: code/docs bumped without regen, or fetched from the wrong version. +def test_spec_path_agrees_across_code_spec_docs_and_workflow(): + # An API-version bump must land everywhere at once: DEFAULT_BASE_URL, + # openapi.json, CONTRIBUTING.md, and the pinned spec-drift fetch URL. + api_path = urlparse(DEFAULT_BASE_URL).path 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 urlparse(spec["servers"][0]["url"]).path == api_path + assert f"{api_path}/api-docs" in CONTRIB + drift = (ROOT / ".github" / "workflows" / "spec-drift.yml").read_text() + assert f"SPEC_URL: {DEFAULT_BASE_URL}/api-docs" in drift def test_single_spdx_year_across_package(): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 7ab0c6f..de47ffb 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -46,6 +46,13 @@ def test_429_preserves_retry_after(self): assert exc_info.value.retry_after == 30.0 assert exc_info.value.request_id == "req-789" + def test_retry_after_surfaces_on_any_status(self): + # RFC 9110 allows Retry-After on e.g. 503; the parsed value must not + # be dropped just because the status is not 429. + with pytest.raises(ServerError) as exc_info: + raise_for_status(503, retry_after=5.0) + assert exc_info.value.retry_after == 5.0 + def test_unknown_4xx_raises_api_error(self): with pytest.raises(APIError) as exc_info: raise_for_status(418) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4d320cf..c2f8c51 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10,7 +10,7 @@ AsyncEventHook, HookTransport, ) -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, FakeTransport _BACKENDS_URL = f"{BASE_URL}/backends" @@ -164,28 +164,6 @@ def test_no_hooks_skips_hook_transport(self): assert isinstance(transport, ErrorRaisingTransport) -class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): - def __init__(self, response: httpx.Response): - self._response = response - - def handle_request(self, request: httpx.Request) -> httpx.Response: - return self._response - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - return self._response - - -class RaisingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): - def __init__(self, exc: Exception): - self._exc = exc - - def handle_request(self, request: httpx.Request) -> httpx.Response: - raise self._exc - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - raise self._exc - - class TestHookTransportExecution: def test_hooks_called_in_order(self): hook1, hook2 = RecordingHook(), RecordingHook() @@ -224,7 +202,7 @@ def test_on_error_fires_on_exception(self): request = httpx.Request("GET", _BACKENDS_URL) with pytest.raises(NotFoundError): - HookTransport(RaisingTransport(error), (hook,)).handle_request(request) + HookTransport(FakeTransport(error), (hook,)).handle_request(request) assert len(hook.errors) == 1 assert hook.errors[0] == (request, error) @@ -241,7 +219,7 @@ def on_response(self, request, response): pass with pytest.raises(NotFoundError): - HookTransport(RaisingTransport(NotFoundError(404)), (MinimalHook(),)).handle_request( + HookTransport(FakeTransport(NotFoundError(404)), (MinimalHook(),)).handle_request( httpx.Request("GET", _BACKENDS_URL) ) @@ -251,7 +229,7 @@ async def test_async_on_error_fires(self): request = httpx.Request("GET", _BACKENDS_URL) with pytest.raises(NotFoundError): - await HookTransport(RaisingTransport(error), (hook,)).handle_async_request(request) + await HookTransport(FakeTransport(error), (hook,)).handle_async_request(request) assert len(hook.errors) == 1 assert hook.errors[0] == (request, error) @@ -266,7 +244,7 @@ async def on_request(self, request): async def on_response(self, request, response): pass - transport = HookTransport(RaisingTransport(NotFoundError(404)), (MinimalAsyncHook(),)) + transport = HookTransport(FakeTransport(NotFoundError(404)), (MinimalAsyncHook(),)) with pytest.raises(NotFoundError): await transport.handle_async_request(httpx.Request("GET", _BACKENDS_URL)) @@ -338,13 +316,13 @@ def mapper(exc): return DownstreamError(f"translated: {exc}") return exc - transport = HookTransport(RaisingTransport(NotFoundError(404)), error_mapper=mapper) + transport = HookTransport(FakeTransport(NotFoundError(404)), error_mapper=mapper) with pytest.raises(DownstreamError, match="translated"): transport.handle_request(httpx.Request("GET", _BACKENDS_URL)) def test_sync_mapper_passthrough(self): - transport = HookTransport(RaisingTransport(NotFoundError(404)), error_mapper=lambda exc: exc) + transport = HookTransport(FakeTransport(NotFoundError(404)), error_mapper=lambda exc: exc) with pytest.raises(NotFoundError): transport.handle_request(httpx.Request("GET", _BACKENDS_URL)) @@ -358,7 +336,7 @@ def mapper(exc): return DownstreamError(f"mapped: {exc}") return exc - transport = HookTransport(RaisingTransport(NotFoundError(404)), error_mapper=mapper) + transport = HookTransport(FakeTransport(NotFoundError(404)), error_mapper=mapper) with pytest.raises(DownstreamError, match="mapped"): await transport.handle_async_request(httpx.Request("GET", _BACKENDS_URL)) diff --git a/tests/test_ionq_client.py b/tests/test_ionq_client.py index 1104e8b..3173c98 100644 --- a/tests/test_ionq_client.py +++ b/tests/test_ionq_client.py @@ -80,6 +80,18 @@ def test_token_not_in_repr(self): c.get_async_httpx_client() assert "super-secret-key" not in repr(c) + def test_headers_kwarg_merges(self): + c = IonQClient(api_key="key", headers={"X-Caller": "1", "User-Agent": "overridden"}) + hc = c.get_httpx_client() + assert hc.headers["X-Caller"] == "1" + assert hc.headers["User-Agent"].startswith("ionq-core/") # generated UA wins + assert c.get_async_httpx_client().headers["X-Caller"] == "1" + + def test_cookies_reach_both_clients(self): + c = IonQClient(api_key="key", cookies={"a": "b"}) + assert c.get_httpx_client().cookies["a"] == "b" + assert c.get_async_httpx_client().cookies["a"] == "b" + def test_caller_headers_dict_not_mutated(self): # A headers dict passed by the caller is caller-owned; injecting the # Authorization value into it would leak the key to any other client diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 2fc13ea..aa08b54 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -25,19 +25,6 @@ async def test_async_none_response(self, httpx_mock, auth_client): async for _ in aiter_jobs(auth_client): pass - def test_sync_session_none_response(self, httpx_mock, auth_client): - httpx_mock.add_response(status_code=500) - auth_client.raise_on_unexpected_status = False - with pytest.raises(IonQError, match="Failed to fetch"): - list(iter_session_jobs(auth_client, "sess-1")) - - async def test_async_session_none_response(self, httpx_mock, auth_client): - httpx_mock.add_response(status_code=500) - auth_client.raise_on_unexpected_status = False - with pytest.raises(IonQError, match="Failed to fetch"): - async for _ in aiter_session_jobs(auth_client, "sess-1"): - pass - class TestIterJobs: def test_single_page(self, httpx_mock, auth_client): diff --git a/tests/test_transport.py b/tests/test_transport.py index a996197..2e37c2a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -12,36 +12,16 @@ from ionq_core.exceptions import ( APIConnectionError, APITimeoutError, - AuthenticationError, BadRequestError, NotFoundError, RateLimitError, ServerError, ) -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, FakeTransport _URL = f"{BASE_URL}/backends" -class FakeTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): - def __init__(self, responses): - self._responses = list(responses) - self.call_count = 0 - - def _next(self): - self.call_count += 1 - item = self._responses.pop(0) - if isinstance(item, Exception): - raise item - return item - - def handle_request(self, request): - return self._next() - - async def handle_async_request(self, request): - return self._next() - - def _resp(status_code, headers=None, json_body=None): return httpx.Response(status_code, headers=headers or {}, json=json_body) @@ -51,14 +31,11 @@ def _req(method="GET"): def _wrap(responses): - fake = FakeTransport(responses) + fake = FakeTransport(*responses) return ErrorRaisingTransport(fake), fake class TestBuildTransport: - def test_returns_error_raising(self): - assert isinstance(build_transport(), ErrorRaisingTransport) - def test_does_not_retry_post_requests(self): # POSTs submit billable jobs/sessions and the API has no idempotency # keys, so a retry after an ambiguous 5xx could duplicate work. @@ -81,8 +58,9 @@ def _ssl_contexts(transport): return sync_ctx, async_ctx def test_default_verifies_certificates(self): - for ctx in self._ssl_contexts(build_transport()): - assert ctx.verify_mode == ssl.CERT_REQUIRED + sync_ctx, async_ctx = self._ssl_contexts(build_transport()) + assert sync_ctx is async_ctx # built once, shared by both transports + assert sync_ctx.verify_mode == ssl.CERT_REQUIRED def test_verify_false_disables_verification(self): for ctx in self._ssl_contexts(build_transport(verify=False)): @@ -100,11 +78,6 @@ def test_success_passes_through(self): assert transport.handle_request(_req()).status_code == 200 assert fake.call_count == 1 - def test_401_raises_auth_error(self): - transport, _ = _wrap([_resp(401)]) - with pytest.raises(AuthenticationError): - transport.handle_request(_req()) - def test_503_raises_server_error(self): transport, _ = _wrap([_resp(503)]) with pytest.raises(ServerError) as exc_info: @@ -117,8 +90,9 @@ def test_error_body_parsed(self): transport.handle_request(_req()) assert exc_info.value.body == {"error": "Bad Request", "message": "Invalid input"} - def test_error_message_surfaced(self): - transport, _ = _wrap([_resp(404, json_body={"message": "Job not found"})]) + @pytest.mark.parametrize("key", ["message", "error"]) + def test_error_message_surfaced(self, key): + transport, _ = _wrap([_resp(404, json_body={key: "Job not found"})]) with pytest.raises(NotFoundError, match="Job not found"): transport.handle_request(_req()) From 559cd02d56aad9adb519c69e72192605573ce8f6 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:40:21 -0400 Subject: [PATCH 3/3] Trim the pipeline, CI, and docs to their minimal working form - The SPDX post-hook also squeezes trailing newlines, so generated output satisfies pre-commit's end-of-file-fixer without fighting the staleness gate (39 files lose their blank final line). The no-op ruff check --fix-only hook is dropped; ruff format stays, it is load-bearing for the rendered __init__.py. - generated.yml: the dead overlay-missing fallback is removed (the overlay is tracked and required) and UV_FROZEN is set so the regen toolchain always matches uv.lock. - CONTRIBUTING mirrors the fallback removal; AGENTS.md's drifted third copy of the regen command becomes a pointer to CONTRIBUTING; both files use uvx pre-commit (plain pre-commit is not installed by uv sync); two self-duplicated sentences and a drift-sentinel claim no test enforces are removed. - README drops a stale parameter enumeration and a version-print fence. - dependabot drops the default day: monday; zizmor.yml drops the default token; pyproject drops two exclude_also patterns with zero occurrences and the unused RUF012 per-file-ignore; .gitattributes drops linguist-vendored on openapi.json (subsumed by linguist-generated). --- .gitattributes | 2 +- .github/dependabot.yml | 3 --- .github/workflows/generated.yml | 12 +++------ .github/workflows/spec-drift.yml | 5 ++-- .github/workflows/zizmor.yml | 1 - AGENTS.md | 25 ++++--------------- CONTRIBUTING.md | 14 +++-------- README.md | 9 ++----- ionq_core/api/default/get_job_artifact.py | 1 - ionq_core/client.py | 1 - ionq_core/models/add_job_results_payload.py | 1 - ionq_core/models/add_job_results_response.py | 1 - ionq_core/models/base_job.py | 1 - .../circuit_job_compilation_settings.py | 1 - .../models/circuit_job_creation_payload.py | 1 - ionq_core/models/circuit_job_result.py | 1 - ionq_core/models/circuit_job_settings.py | 1 - ionq_core/models/circuit_job_stats.py | 1 - ionq_core/models/create_session_request.py | 1 - ionq_core/models/failure.py | 1 - ionq_core/models/gate_native_gate.py | 1 - ionq_core/models/gate_qis_gate.py | 1 - ionq_core/models/get_circuit_job_response.py | 1 - ionq_core/models/get_job_cost_response.py | 1 - .../models/get_job_estimate_query_params.py | 1 - ionq_core/models/get_job_estimate_response.py | 1 - ionq_core/models/get_job_response.py | 1 - ionq_core/models/get_jobs_query_params.py | 1 - ionq_core/models/get_jobs_response.py | 1 - ionq_core/models/get_sessions_query_params.py | 1 - ionq_core/models/job_canceled_response.py | 1 - ionq_core/models/job_creation_response.py | 1 - ionq_core/models/job_deleted_response.py | 1 - .../models/jobs_bulk_operation_request.py | 1 - ionq_core/models/jobs_canceled_response.py | 1 - ionq_core/models/jobs_deleted_response.py | 1 - ionq_core/models/json_multi_circuit_job.py | 1 - ionq_core/models/native_circuit.py | 1 - ionq_core/models/native_circuit_input.py | 1 - ionq_core/models/noise.py | 1 - ionq_core/models/qis_circuit.py | 1 - ionq_core/models/qis_circuit_input.py | 1 - ionq_core/models/session.py | 1 - ionq_core/models/session_cost_limit.py | 1 - ionq_core/models/session_settings.py | 1 - ionq_core/models/session_settings_request.py | 1 - ionq_core/models/sessions_response.py | 1 - openapi-python-client-config.yaml | 5 ++-- pyproject.toml | 5 ---- 49 files changed, 22 insertions(+), 98 deletions(-) diff --git a/.gitattributes b/.gitattributes index 0a1cc41..431d8ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,7 +7,7 @@ ionq_core/api/** linguist-generated=true ionq_core/models/** linguist-generated=true # Vendored upstream OpenAPI spec. -openapi.json linguist-vendored=true linguist-generated=true +openapi.json linguist-generated=true # Lockfile. uv.lock linguist-generated=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 93ecb4c..d5eb073 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,6 @@ updates: directory: "/" schedule: interval: "weekly" - day: "monday" labels: ["dependencies"] groups: actions: @@ -16,7 +15,6 @@ updates: directory: "/" schedule: interval: "weekly" - day: "monday" labels: ["dependencies"] groups: python: @@ -31,7 +29,6 @@ updates: directory: "/" schedule: interval: "weekly" - day: "monday" labels: ["dependencies"] groups: pre-commit: diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index ea6db35..9ee8205 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -10,6 +10,9 @@ concurrency: permissions: contents: read +env: + UV_FROZEN: true + jobs: staleness: runs-on: ubuntu-latest @@ -20,16 +23,9 @@ jobs: 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 oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json uv run openapi-python-client generate \ --path /tmp/patched-spec.json \ --meta none \ diff --git a/.github/workflows/spec-drift.yml b/.github/workflows/spec-drift.yml index fe76f0e..2e66d05 100644 --- a/.github/workflows/spec-drift.yml +++ b/.github/workflows/spec-drift.yml @@ -14,8 +14,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 env: - # Pinned (keep in sync with CONTRIBUTING.md); never derived from openapi.json, - # so a tampered vendored spec cannot point the check at a mirror that hides it. + # Pinned (tests/test_docs_consistency.py keeps it aligned with DEFAULT_BASE_URL); + # never derived from openapi.json, so a tampered vendored spec cannot point the + # check at a mirror that hides it. SPEC_URL: https://api.ionq.co/v0.4/api-docs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 15bc565..717ef79 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,6 +21,5 @@ jobs: persist-credentials: false - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 with: - token: ${{ secrets.GITHUB_TOKEN }} advanced-security: false annotations: true diff --git a/AGENTS.md b/AGENTS.md index eb67dd5..0d94d28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Instructions for AI agents working in this repository. Humans should read [`CONT ```sh uv sync # canonical; uv.lock is committed and CI runs UV_FROZEN=true -pre-commit install +uvx pre-commit install ``` `uv` is required. Don't use `pip` / `poetry` for dev workflows — they bypass the lockfile. @@ -18,7 +18,7 @@ pre-commit install ## Run ```sh -uv run pytest # unit tests; 100% branch coverage gate on hand-written code +uv run pytest uv run ruff check uv run ruff format --check # drop --check to apply uv run ty check ionq_core/ @@ -51,21 +51,7 @@ When you hit a bug in generated code: ## Regenerating the client -Run exactly what's in [`CONTRIBUTING.md`](CONTRIBUTING.md) and mirrored in [`.github/workflows/generated.yml`](.github/workflows/generated.yml): - -```sh -uv sync --group regen -# If v0.4 isn't found, search for the latest API version. -curl -sf https://api.ionq.co/v0.4/api-docs -o openapi.json -uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json -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 -``` - -Commit regenerated files in the same PR as the spec/template/overlay change that produced them. +Run the block in [`CONTRIBUTING.md`](CONTRIBUTING.md#regenerating-the-client) verbatim; CI runs the same invocation via [`generated.yml`](.github/workflows/generated.yml) on every PR. The spec source is `https://api.ionq.co/v0.4/api-docs` (if that version 404s, search for the current one). Commit regenerated files in the same PR as the spec/template/overlay change that produced them. ## Calling generated endpoints @@ -104,7 +90,7 @@ Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wir ## Drift sentinels — single edits that fan out -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. +Several values are pinned in multiple files (Python floor, 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. ## CI @@ -122,7 +108,7 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu - Branch off `main`. CODEOWNERS is `@ionq/developer-tools`. - PR titles become release-notes lines (`gh release create --generate-notes`). Imperative mood, user-facing, no leading ticket number. - User-visible changes go under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -- 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. +- Release: bump `pyproject.toml` `[project] version`, promote `[Unreleased]` → `[X.Y.Z]` in `CHANGELOG.md`, tag `vX.Y.Z`. ## Things to avoid (and what to do instead) @@ -131,7 +117,6 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu - **Adding a dependency with `pip install`** → `uv add ` (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. - **`Bearer` token examples / `requests` / `aiohttp`** in docs or tests → the library is `httpx`-only and the auth prefix is `apiKey`. - **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. -- **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. - **Adding NumPy or any new runtime dependency** to `gates.py` → keep it pure-Python. ## Where to look first diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbd9201..3318437 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ This project uses [`uv`](https://docs.astral.sh/uv/) for Python and dependency m git clone https://github.com/ionq/ionq-core-python cd ionq-core-python uv sync -pre-commit install +uvx pre-commit install ``` The supported Python floor is set by `requires-python` in `pyproject.toml`; the CI matrix in [`ci.yml`](.github/workflows/ci.yml) is the source of truth for tested interpreters. @@ -38,7 +38,7 @@ The supported Python floor is set by `requires-python` in `pyproject.toml`; the ## Running checks locally ```sh -uv run pytest # unit tests; 100% branch coverage gate on hand-written code +uv run pytest # unit tests uv run ruff check # lint uv run ruff format --check # format check (drop --check to apply) uv run ty check ionq_core/ # type check @@ -64,13 +64,7 @@ To regenerate `ionq_core/api/`, `ionq_core/models/`, and the root-level generate ```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 oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json uv run openapi-python-client generate \ --path /tmp/patched-spec.json \ --meta none \ @@ -88,7 +82,7 @@ Commit the regenerated files alongside the spec or template change that caused t 1. Fork the repository and create a topic branch off `main`. 2. Make your changes; add or update tests for any hand-written code you touch. -3. Run the local checks above and `pre-commit run --all-files`. +3. Run the local checks above and `uvx pre-commit run --all-files`. 4. Push and open a PR against `main`. Fill in the **Summary** and **Test plan** sections of the template. 5. CI must pass: lint, tests across the supported-Python matrix, the generated-code staleness check, `pip-audit`, and `zizmor` when workflow files change. A reviewer from `@ionq/developer-tools` will review. diff --git a/README.md b/README.md index 992bbde..401c372 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,11 @@ print(probs.additional_properties) Each generated endpoint module exposes four callables: `sync`, `sync_detailed`, `asyncio`, and `asyncio_detailed`. The `sync` and `asyncio` variants return the parsed body; the `_detailed` variants return a `Response[T]` with the status code, headers, and parsed body. -For options (`api_key`, `base_url`, `max_retries`, `timeout`, `extension`), error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). +For client options, error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). ## Versioning -This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with: - -```python -import ionq_core -print(ionq_core.__version__) -``` +This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with `ionq_core.__version__`. The full release history is in [CHANGELOG.md](https://github.com/ionq/ionq-core-python/blob/main/CHANGELOG.md). diff --git a/ionq_core/api/default/get_job_artifact.py b/ionq_core/api/default/get_job_artifact.py index 06bb14a..18f1a05 100644 --- a/ionq_core/api/default/get_job_artifact.py +++ b/ionq_core/api/default/get_job_artifact.py @@ -126,4 +126,3 @@ async def asyncio_detailed( ) return _build_response(client=client, response=response) - diff --git a/ionq_core/client.py b/ionq_core/client.py index fb2466b..73163d8 100644 --- a/ionq_core/client.py +++ b/ionq_core/client.py @@ -272,4 +272,3 @@ async def __aenter__(self) -> "AuthenticatedClient": async def __aexit__(self, *args: Any, **kwargs: Any) -> None: """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" await self.get_async_httpx_client().__aexit__(*args, **kwargs) - diff --git a/ionq_core/models/add_job_results_payload.py b/ionq_core/models/add_job_results_payload.py index 46d3aa6..e672f59 100644 --- a/ionq_core/models/add_job_results_payload.py +++ b/ionq_core/models/add_job_results_payload.py @@ -81,4 +81,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return add_job_results_payload - diff --git a/ionq_core/models/add_job_results_response.py b/ionq_core/models/add_job_results_response.py index 64089ae..954621e 100644 --- a/ionq_core/models/add_job_results_response.py +++ b/ionq_core/models/add_job_results_response.py @@ -59,4 +59,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return add_job_results_response - diff --git a/ionq_core/models/base_job.py b/ionq_core/models/base_job.py index d63afbc..89fea32 100644 --- a/ionq_core/models/base_job.py +++ b/ionq_core/models/base_job.py @@ -431,4 +431,3 @@ def _parse_results(data: object) -> JsonObject | None: ) return base_job - diff --git a/ionq_core/models/circuit_job_compilation_settings.py b/ionq_core/models/circuit_job_compilation_settings.py index de44785..923fc6a 100644 --- a/ionq_core/models/circuit_job_compilation_settings.py +++ b/ionq_core/models/circuit_job_compilation_settings.py @@ -88,4 +88,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return circuit_job_compilation_settings - diff --git a/ionq_core/models/circuit_job_creation_payload.py b/ionq_core/models/circuit_job_creation_payload.py index 88085bc..26185d0 100644 --- a/ionq_core/models/circuit_job_creation_payload.py +++ b/ionq_core/models/circuit_job_creation_payload.py @@ -217,4 +217,3 @@ def _parse_input_(data: object) -> NativeCircuitInput | QisCircuitInput: ) return circuit_job_creation_payload - diff --git a/ionq_core/models/circuit_job_result.py b/ionq_core/models/circuit_job_result.py index 7d20220..b02ba18 100644 --- a/ionq_core/models/circuit_job_result.py +++ b/ionq_core/models/circuit_job_result.py @@ -120,4 +120,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return circuit_job_result - diff --git a/ionq_core/models/circuit_job_settings.py b/ionq_core/models/circuit_job_settings.py index 788dae6..4ea141e 100644 --- a/ionq_core/models/circuit_job_settings.py +++ b/ionq_core/models/circuit_job_settings.py @@ -98,4 +98,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return circuit_job_settings - diff --git a/ionq_core/models/circuit_job_stats.py b/ionq_core/models/circuit_job_stats.py index 104cb37..adfe0e7 100644 --- a/ionq_core/models/circuit_job_stats.py +++ b/ionq_core/models/circuit_job_stats.py @@ -121,4 +121,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return circuit_job_stats - diff --git a/ionq_core/models/create_session_request.py b/ionq_core/models/create_session_request.py index 3314490..fa9fd0a 100644 --- a/ionq_core/models/create_session_request.py +++ b/ionq_core/models/create_session_request.py @@ -84,4 +84,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return create_session_request - diff --git a/ionq_core/models/failure.py b/ionq_core/models/failure.py index 78c0346..5be7917 100644 --- a/ionq_core/models/failure.py +++ b/ionq_core/models/failure.py @@ -73,4 +73,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return failure - diff --git a/ionq_core/models/gate_native_gate.py b/ionq_core/models/gate_native_gate.py index dd1fb0a..d6a866d 100644 --- a/ionq_core/models/gate_native_gate.py +++ b/ionq_core/models/gate_native_gate.py @@ -144,4 +144,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return gate_native_gate - diff --git a/ionq_core/models/gate_qis_gate.py b/ionq_core/models/gate_qis_gate.py index 2cb1dd1..e716bb7 100644 --- a/ionq_core/models/gate_qis_gate.py +++ b/ionq_core/models/gate_qis_gate.py @@ -121,4 +121,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return gate_qis_gate - diff --git a/ionq_core/models/get_circuit_job_response.py b/ionq_core/models/get_circuit_job_response.py index c7ac59a..738900c 100644 --- a/ionq_core/models/get_circuit_job_response.py +++ b/ionq_core/models/get_circuit_job_response.py @@ -468,4 +468,3 @@ def _parse_child_job_ids(data: object) -> list[str] | None: ) return get_circuit_job_response - diff --git a/ionq_core/models/get_job_cost_response.py b/ionq_core/models/get_job_cost_response.py index fc8ccce..6381356 100644 --- a/ionq_core/models/get_job_cost_response.py +++ b/ionq_core/models/get_job_cost_response.py @@ -98,4 +98,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return get_job_cost_response - diff --git a/ionq_core/models/get_job_estimate_query_params.py b/ionq_core/models/get_job_estimate_query_params.py index 87b5ab0..ba765d1 100644 --- a/ionq_core/models/get_job_estimate_query_params.py +++ b/ionq_core/models/get_job_estimate_query_params.py @@ -115,4 +115,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return get_job_estimate_query_params - diff --git a/ionq_core/models/get_job_estimate_response.py b/ionq_core/models/get_job_estimate_response.py index 1c04748..fed5353 100644 --- a/ionq_core/models/get_job_estimate_response.py +++ b/ionq_core/models/get_job_estimate_response.py @@ -131,4 +131,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return get_job_estimate_response - diff --git a/ionq_core/models/get_job_response.py b/ionq_core/models/get_job_response.py index e541198..b478739 100644 --- a/ionq_core/models/get_job_response.py +++ b/ionq_core/models/get_job_response.py @@ -468,4 +468,3 @@ def _parse_child_job_ids(data: object) -> list[str] | None: ) return get_job_response - diff --git a/ionq_core/models/get_jobs_query_params.py b/ionq_core/models/get_jobs_query_params.py index d147162..508ecb7 100644 --- a/ionq_core/models/get_jobs_query_params.py +++ b/ionq_core/models/get_jobs_query_params.py @@ -145,4 +145,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return get_jobs_query_params - diff --git a/ionq_core/models/get_jobs_response.py b/ionq_core/models/get_jobs_response.py index 83898aa..c5c52da 100644 --- a/ionq_core/models/get_jobs_response.py +++ b/ionq_core/models/get_jobs_response.py @@ -92,4 +92,3 @@ def _parse_next_(data: object) -> None | str: ) return get_jobs_response - diff --git a/ionq_core/models/get_sessions_query_params.py b/ionq_core/models/get_sessions_query_params.py index 40887ae..1782578 100644 --- a/ionq_core/models/get_sessions_query_params.py +++ b/ionq_core/models/get_sessions_query_params.py @@ -61,4 +61,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return get_sessions_query_params - diff --git a/ionq_core/models/job_canceled_response.py b/ionq_core/models/job_canceled_response.py index 084b57a..17ed177 100644 --- a/ionq_core/models/job_canceled_response.py +++ b/ionq_core/models/job_canceled_response.py @@ -73,4 +73,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return job_canceled_response - diff --git a/ionq_core/models/job_creation_response.py b/ionq_core/models/job_creation_response.py index c32b224..bfaaa35 100644 --- a/ionq_core/models/job_creation_response.py +++ b/ionq_core/models/job_creation_response.py @@ -88,4 +88,3 @@ def _parse_session_id(data: object) -> None | str: ) return job_creation_response - diff --git a/ionq_core/models/job_deleted_response.py b/ionq_core/models/job_deleted_response.py index 49ce5ef..e81ae90 100644 --- a/ionq_core/models/job_deleted_response.py +++ b/ionq_core/models/job_deleted_response.py @@ -73,4 +73,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return job_deleted_response - diff --git a/ionq_core/models/jobs_bulk_operation_request.py b/ionq_core/models/jobs_bulk_operation_request.py index e14a515..3d34bf7 100644 --- a/ionq_core/models/jobs_bulk_operation_request.py +++ b/ionq_core/models/jobs_bulk_operation_request.py @@ -63,4 +63,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return jobs_bulk_operation_request - diff --git a/ionq_core/models/jobs_canceled_response.py b/ionq_core/models/jobs_canceled_response.py index 350d773..fda30a2 100644 --- a/ionq_core/models/jobs_canceled_response.py +++ b/ionq_core/models/jobs_canceled_response.py @@ -76,4 +76,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return jobs_canceled_response - diff --git a/ionq_core/models/jobs_deleted_response.py b/ionq_core/models/jobs_deleted_response.py index 1e41d08..eb22632 100644 --- a/ionq_core/models/jobs_deleted_response.py +++ b/ionq_core/models/jobs_deleted_response.py @@ -76,4 +76,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return jobs_deleted_response - diff --git a/ionq_core/models/json_multi_circuit_job.py b/ionq_core/models/json_multi_circuit_job.py index 5e4884e..a6cdc84 100644 --- a/ionq_core/models/json_multi_circuit_job.py +++ b/ionq_core/models/json_multi_circuit_job.py @@ -200,4 +200,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return json_multi_circuit_job - diff --git a/ionq_core/models/native_circuit.py b/ionq_core/models/native_circuit.py index a36006f..b6f6813 100644 --- a/ionq_core/models/native_circuit.py +++ b/ionq_core/models/native_circuit.py @@ -143,4 +143,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return native_circuit - diff --git a/ionq_core/models/native_circuit_input.py b/ionq_core/models/native_circuit_input.py index 47c98e9..504a90d 100644 --- a/ionq_core/models/native_circuit_input.py +++ b/ionq_core/models/native_circuit_input.py @@ -100,4 +100,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return native_circuit_input - diff --git a/ionq_core/models/noise.py b/ionq_core/models/noise.py index ac635ed..21a3ff3 100644 --- a/ionq_core/models/noise.py +++ b/ionq_core/models/noise.py @@ -75,4 +75,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return noise - diff --git a/ionq_core/models/qis_circuit.py b/ionq_core/models/qis_circuit.py index d4c08df..2029793 100644 --- a/ionq_core/models/qis_circuit.py +++ b/ionq_core/models/qis_circuit.py @@ -143,4 +143,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return qis_circuit - diff --git a/ionq_core/models/qis_circuit_input.py b/ionq_core/models/qis_circuit_input.py index 358b3c5..89dee76 100644 --- a/ionq_core/models/qis_circuit_input.py +++ b/ionq_core/models/qis_circuit_input.py @@ -98,4 +98,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return qis_circuit_input - diff --git a/ionq_core/models/session.py b/ionq_core/models/session.py index 27678b3..c555284 100644 --- a/ionq_core/models/session.py +++ b/ionq_core/models/session.py @@ -241,4 +241,3 @@ def _parse_started_at(data: object) -> datetime.datetime | None: ) return session - diff --git a/ionq_core/models/session_cost_limit.py b/ionq_core/models/session_cost_limit.py index 721254d..b8b9bbf 100644 --- a/ionq_core/models/session_cost_limit.py +++ b/ionq_core/models/session_cost_limit.py @@ -67,4 +67,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return session_cost_limit - diff --git a/ionq_core/models/session_settings.py b/ionq_core/models/session_settings.py index 940ea98..cefefae 100644 --- a/ionq_core/models/session_settings.py +++ b/ionq_core/models/session_settings.py @@ -114,4 +114,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return session_settings - diff --git a/ionq_core/models/session_settings_request.py b/ionq_core/models/session_settings_request.py index f51ddc9..8b1d0df 100644 --- a/ionq_core/models/session_settings_request.py +++ b/ionq_core/models/session_settings_request.py @@ -94,4 +94,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return session_settings_request - diff --git a/ionq_core/models/sessions_response.py b/ionq_core/models/sessions_response.py index ecb9e4f..609b60b 100644 --- a/ionq_core/models/sessions_response.py +++ b/ionq_core/models/sessions_response.py @@ -85,4 +85,3 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) return sessions_response - diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 89906a0..759a047 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -15,6 +15,7 @@ post_hooks: # "", ".", and "..": quote() leaves dots unencoded, so ".." would delete a fixed # URL segment under RFC 3986 normalization (/sessions/../jobs -> /jobs). - "perl -pi -e 's/^from urllib.parse import quote$/from ..._url import quote_path_param/; s/quote\\(str\\((\\w+)\\), safe=\"\"\\)/quote_path_param($1)/g' $(find api -name '*.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')" - - "ruff check . --fix-only" + # Also squeeze trailing newlines to one so generated output satisfies + # pre-commit's end-of-file-fixer without fighting the staleness gate. + - "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/;s/\\n+\\z/\\n/' $(find . -name '*.py')" - "ruff format ." diff --git a/pyproject.toml b/pyproject.toml index f227f38..29f7c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,6 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] [tool.ruff.lint.isort] known-first-party = ["ionq_core"] -[tool.ruff.lint.per-file-ignores] -"tests/**" = ["RUF012"] - [tool.ty.environment] python-version = "3.11" @@ -124,6 +121,4 @@ exclude_also = [ "if TYPE_CHECKING:", "class .*\\bProtocol\\):", "except PackageNotFoundError:", - "@overload", - 'if __name__ == "__main__":', ]