Skip to content

Commit 144a0c0

Browse files
authored
Expand integration tests, fix transport response reading bug (#10)
* Reapply "Expand integration tests, fix transport response reading bug" This reverts commit edf974f. * Address review: fix async transport, simplify tests Transport: - Move response.read()/aread() before _should_retry so exhausted retries also have a read body for error parsing - Add await response.aread() in async transport (was sync-only) - Remove response.read() from _parse_error_body (caller's job) Tests: - Consolidate redundant API calls via shared fixtures - Fix session tests: status() returns a string, not Session object - Actually test context manager protocol with `with` statement - Use itertools.islice instead of zip(range()) - Remove redundant warnings.catch_warnings blocks (conftest handles it) - Extract _submit() helper for repeated job creation - Add docstring explaining why async_client fixture exists separately * Simplify integration tests: flatten classes, use fixtures - Flatten TestListBackends into plain functions with a shared fixture - Flatten single-test classes (cancel, delete, estimate, poll) - Add assert to _submit helper for clearer failures - Use context manager in test_session_lifecycle instead of manual cleanup * Polish: scope fixtures, deduplicate submit helper, drop redundant test - scope="module" on backends fixture (1 API call instead of 3) - scope="class" on completed_job_id fixture (1 call instead of 3) - _submit accepts **overrides, simplifying dry_run test - Remove test_session_context_manager (covered by test_session_lifecycle) * Add comment on unauthenticated fixture, fix trailing blank line * Scope warning suppression to fixture, add track_job to delete test - Move SSL warning filters from module-level to session-scoped autouse fixture so they don't leak into unit test collection - Add track_job to test_delete_job for cleanup if delete fails * style: fix ruff formatting in integration conftest Add missing blank line between fixture and module-level variable. * feat: add optional-dependencies alongside dependency-groups for pip compat pip install -e '.[dev]' only works with [project.optional-dependencies], not [dependency-groups] (PEP 735). Add the dev extra so both pip and uv workflows work. This matches the pattern used by pypa/build. * refactor: simplify pagination and client factory Extract _paginate/_apaginate helpers to eliminate duplicated cursor- following logic across 4 pagination functions. Extract _ext() helper in IonQClient to consolidate the repeated extension-or-default precedence pattern and group sync/async transport chain construction. Net reduction: ~25 lines with improved readability. * refactor: replace misleading _ext() helper and simplify session settings Replace the _ext() closure in IonQClient with direct attribute access. The closure's docstring claimed extension-or-default precedence, but its implementation couldn't actually resolve extension attributes when a default was provided. Change _build_settings() to return UNSET instead of None, matching the auto-generated model's field type and eliminating conditional dict-splat at both sync and async call sites. * refactor: replace hand-written retry transport with httpx-retries Replace the custom RetryTransport/AsyncRetryTransport (192 lines) with httpx-retries, a lightweight library (8.9kB) that provides the same retry logic out of the box: exponential backoff, jitter, Retry-After header support, status code filtering, and idempotent method awareness. What changed: - _transport.py: Remove 7 helper functions and 2 transport classes, replace with build_retry/build_sync_transport/build_async_transport that configure httpx-retries, plus thin ErrorRaisingTransport that converts error responses to structured IonQ exceptions. - _extensions.py: Switch ClientExtension from stdlib dataclasses to attrs for consistency with the rest of the codebase. - ionq_client.py: Use new transport builder functions. Hand-written code reduced from 1,130 to 1,058 lines (-72). All 221 tests pass with 100% branch coverage. * refactor: merge sync/async transport pairs, inline helpers, trim imports Merge ErrorRaisingTransport/AsyncErrorRaisingTransport into a single dual-inheritance class (same pattern httpx-retries uses). Do the same for HookTransport, _ErrorMapperTransport, and their async counterparts. Also inline _parse_error_body and _parse_retry_after into _raise_for_response, remove unused future annotations imports, unify build_sync_transport/build_async_transport into build_transport, and trim docstrings to essential content. Hand-written code: 1058 -> 966 lines (-92, -8.7%). All 220 tests pass with 100% branch coverage. * fix: use Any type for transport field to satisfy ty checker * refactor: unify build_transport into single call RetryTransport(retry=retry) with no explicit inner transport auto- creates both sync and async transports internally (per httpx-retries docs). Remove the async_ parameter and build a single transport that serves both the sync Client and AsyncClient. Hand-written code: 968 -> 965 lines. * refactor: replace Any with DualTransport Protocol, remove future annotations Replace typing.Any on the transport parameter with a proper Protocol that defines both sync and async transport interfaces. Move ErrorRaisingTransport above build_transport to eliminate the forward reference, removing the need for from __future__ import annotations. The remaining files (_session.py, _pagination.py, _polling.py) keep from __future__ import annotations because they use TYPE_CHECKING guards to avoid circular imports - the correct pattern for Python 3.12-3.13 per PEP 563/749. * refactor: simplify hand-written code and deduplicate test helpers - Remove redundant Retry() params (respect_retry_after_header, allowed_methods) that matched library defaults - Use @attrs.frozen shorthand instead of @attrs.define(frozen=True) - Inline _build_user_agent and use ClientExtension() default to simplify IonQClient transport chain setup - Merge sync/async test transport classes using dual-inheritance pattern - Extract shared make_job_json() helper to conftest, replacing duplicated 20+ line job JSON builders in test_polling and test_pagination * chore: upgrade dev dependency floors and lock to latest versions Bump dev dependency floors to match actual major versions in use: - pytest >=8 -> >=9 (required by pytest-httpx >=0.36) - pytest-httpx >=0.35 -> >=0.36 - pytest-asyncio >=0.25 -> >=1 - pytest-cov >=6 -> >=7 - ruff >=0.9 -> >=0.15 Lock file upgraded: - certifi 2026.2.25 -> 2026.4.22 - idna 3.11 -> 3.13 - packaging 26.0 -> 26.1 - pytest-httpx 0.36.0 -> 0.36.2 - ruff 0.15.9 -> 0.15.11 - ty 0.0.29 -> 0.0.32 * fix: handle max_retries=0 correctly and clean up test/config hygiene - Fix bug where ClientExtension(max_retries=0) was silently ignored because Python's `or` operator treats 0 as falsy, falling through to DEFAULT_MAX_RETRIES. Use explicit `is not None` checks instead. - Remove duplicate [project.optional-dependencies] dev section that mirrored [dependency-groups] dev; CI uses `uv sync` which reads dependency-groups. - Add asyncio_default_fixture_loop_scope to silence pytest-asyncio deprecation warning about unset default scope. - Replace 50-iteration response registration loops with pytest-httpx is_reusable=True in polling timeout tests. - Remove 11 unnecessary assert_all_responses_were_requested=False markers from tests where the single registered response is always consumed. - Fix pre-existing ruff format violation in build_transport().
1 parent edf974f commit 144a0c0

26 files changed

Lines changed: 761 additions & 896 deletions

ionq_core/_exceptions.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Structured exceptions for the IonQ API client."""
22

3-
from __future__ import annotations
4-
53

64
class IonQError(Exception):
75
"""Base exception for all IonQ API errors."""
@@ -86,7 +84,6 @@ def raise_for_status(
8684
*,
8785
request_id: str | None = None,
8886
) -> None:
89-
"""Raise the appropriate exception for an error status code."""
9087
if status_code < 400:
9188
return
9289
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ServerError if status_code >= 500 else APIError)

ionq_core/_extensions.py

Lines changed: 26 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,18 @@
1-
"""Extension API for downstream SDKs building on ionq-core.
2-
3-
Provides hooks that downstream libraries (qiskit-ionq, cirq-ionq, etc.)
4-
use to customize client behavior without forking or monkey-patching.
5-
6-
Typical usage::
7-
8-
from ionq_core import IonQClient, ClientExtension
9-
10-
ext = ClientExtension(
11-
user_agent_token="qiskit-ionq/1.1.0",
12-
default_headers={"X-Qiskit-Version": "1.3.0"},
13-
)
14-
client = IonQClient(api_key="...", extension=ext)
15-
"""
16-
17-
from __future__ import annotations
1+
"""Extension API for downstream SDKs building on ionq-core."""
182

193
import logging
204
from collections.abc import Callable
21-
from dataclasses import dataclass, field
225
from typing import Protocol, runtime_checkable
236

7+
import attrs
248
import httpx
259

2610
logger = logging.getLogger("ionq_core")
2711

2812

2913
@runtime_checkable
3014
class EventHook(Protocol):
31-
"""Protocol for observing requests and responses (sync).
32-
33-
Hooks are for observation only (logging, metrics, telemetry) - they
34-
must not mutate the request or response. For mutation, use a custom
35-
httpx transport instead.
36-
37-
``on_error`` is called when the underlying transport raises an
38-
exception (after retries are exhausted). Hooks that do not implement
39-
``on_error`` are silently skipped.
40-
"""
15+
"""Protocol for observing requests and responses (sync)."""
4116

4217
def on_request(self, request: httpx.Request) -> None: ...
4318
def on_response(self, request: httpx.Request, response: httpx.Response) -> None: ...
@@ -51,27 +26,20 @@ async def on_request(self, request: httpx.Request) -> None: ...
5126
async def on_response(self, request: httpx.Request, response: httpx.Response) -> None: ...
5227

5328

54-
@dataclass(frozen=True, slots=True)
29+
@attrs.frozen
5530
class ClientExtension:
56-
"""Declarative configuration bundle for downstream SDK integration.
57-
58-
All fields are optional and additive - they layer on top of the
59-
defaults that IonQClient already provides.
60-
61-
Explicit caller arguments to ``IonQClient()`` take precedence over
62-
extension values, which in turn take precedence over factory defaults.
63-
"""
31+
"""Declarative configuration bundle for downstream SDK integration."""
6432

6533
user_agent_token: str | None = None
66-
default_headers: dict[str, str] = field(default_factory=dict)
34+
default_headers: dict[str, str] = attrs.Factory(dict)
6735
event_hooks: tuple[EventHook, ...] = ()
6836
async_event_hooks: tuple[AsyncEventHook, ...] = ()
6937
retryable_status_codes: frozenset[int] | None = None
7038
max_retries: int | None = None
7139
timeout: httpx.Timeout | None = None
7240
transport_wrapper: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None
7341
async_transport_wrapper: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None
74-
error_mapper: Callable[[Exception], Exception] | None = None # return same object to skip mapping
42+
error_mapper: Callable[[Exception], Exception] | None = None
7543
debug_hooks: bool = False
7644

7745

@@ -101,92 +69,52 @@ async def _afire_hooks(hooks: tuple, method: str, *args, debug: bool = False) ->
10169
logger.exception("%s raised; ignoring", method)
10270

10371

104-
class HookTransport(httpx.BaseTransport):
105-
"""Transport decorator that invokes EventHook instances.
106-
107-
Sits between the retry transport (inner) and the user wrapper (outer).
108-
Hooks observe the final request/response after retries resolve.
109-
``on_error`` fires when the inner transport raises.
110-
"""
72+
class HookTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
73+
"""Transport decorator that invokes EventHook instances and optionally maps exceptions."""
11174

112-
def __init__(self, transport: httpx.BaseTransport, hooks: tuple[EventHook, ...], *, debug: bool = False) -> None:
75+
def __init__(
76+
self,
77+
transport,
78+
hooks: tuple = (),
79+
*,
80+
debug: bool = False,
81+
error_mapper: Callable[[Exception], Exception] | None = None,
82+
) -> None:
11383
self._transport = transport
11484
self._hooks = hooks
11585
self._debug = debug
86+
self._error_mapper = error_mapper
87+
88+
def _map_error(self, exc: Exception) -> None:
89+
if self._error_mapper is not None:
90+
mapped = self._error_mapper(exc)
91+
if mapped is not exc:
92+
raise mapped from exc
11693

11794
def handle_request(self, request: httpx.Request) -> httpx.Response:
11895
_fire_hooks(self._hooks, "on_request", request, debug=self._debug)
11996
try:
12097
response = self._transport.handle_request(request)
12198
except Exception as exc:
12299
_fire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
100+
self._map_error(exc)
123101
raise
124102
_fire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
125103
return response
126104

127-
def close(self) -> None:
128-
self._transport.close()
129-
130-
131-
class AsyncHookTransport(httpx.AsyncBaseTransport):
132-
"""Async counterpart of HookTransport."""
133-
134-
def __init__(
135-
self, transport: httpx.AsyncBaseTransport, hooks: tuple[AsyncEventHook, ...], *, debug: bool = False
136-
) -> None:
137-
self._transport = transport
138-
self._hooks = hooks
139-
self._debug = debug
140-
141105
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
142106
await _afire_hooks(self._hooks, "on_request", request, debug=self._debug)
143107
try:
144108
response = await self._transport.handle_async_request(request)
145109
except Exception as exc:
146110
await _afire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
111+
self._map_error(exc)
147112
raise
148113
await _afire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
149114
return response
150115

151-
async def aclose(self) -> None:
152-
await self._transport.aclose()
153-
154-
155-
class _ErrorMapperTransport(httpx.BaseTransport):
156-
"""Translates exceptions via an error_mapper callback for downstream SDKs."""
157-
158-
def __init__(self, transport: httpx.BaseTransport, mapper: Callable[[Exception], Exception]) -> None:
159-
self._transport = transport
160-
self._mapper = mapper
161-
162-
def handle_request(self, request: httpx.Request) -> httpx.Response:
163-
try:
164-
return self._transport.handle_request(request)
165-
except Exception as exc:
166-
mapped = self._mapper(exc)
167-
if mapped is not exc:
168-
raise mapped from exc
169-
raise
170-
171116
def close(self) -> None:
172117
self._transport.close()
173118

174-
175-
class _AsyncErrorMapperTransport(httpx.AsyncBaseTransport):
176-
"""Async counterpart of _ErrorMapperTransport."""
177-
178-
def __init__(self, transport: httpx.AsyncBaseTransport, mapper: Callable[[Exception], Exception]) -> None:
179-
self._transport = transport
180-
self._mapper = mapper
181-
182-
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
183-
try:
184-
return await self._transport.handle_async_request(request)
185-
except Exception as exc:
186-
mapped = self._mapper(exc)
187-
if mapped is not exc:
188-
raise mapped from exc
189-
raise
190-
191119
async def aclose(self) -> None:
192120
await self._transport.aclose()

ionq_core/_gates.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
Matrices are returned as nested tuples of complex numbers.
66
"""
77

8-
from __future__ import annotations
9-
108
import cmath
119
import math
1210

0 commit comments

Comments
 (0)