Skip to content

Commit 38d2af8

Browse files
committed
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
1 parent 59c24a9 commit 38d2af8

14 files changed

Lines changed: 161 additions & 299 deletions

ionq_core/_exceptions.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ def raise_for_status(
8484
*,
8585
request_id: str | None = None,
8686
) -> None:
87-
"""Raise the appropriate exception for an error status code."""
8887
if status_code < 400:
8988
return
9089
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, ServerError if status_code >= 500 else APIError)

ionq_core/_extensions.py

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
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-
"""
1+
"""Extension API for downstream SDKs building on ionq-core."""
162

173
import logging
184
from collections.abc import Callable
@@ -40,7 +26,7 @@ async def on_request(self, request: httpx.Request) -> None: ...
4026
async def on_response(self, request: httpx.Request, response: httpx.Response) -> None: ...
4127

4228

43-
@attrs.define(frozen=True)
29+
@attrs.frozen
4430
class ClientExtension:
4531
"""Declarative configuration bundle for downstream SDK integration."""
4632

@@ -84,19 +70,34 @@ async def _afire_hooks(hooks: tuple, method: str, *args, debug: bool = False) ->
8470

8571

8672
class HookTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
87-
"""Transport decorator that invokes EventHook instances."""
88-
89-
def __init__(self, transport, hooks: tuple, *, debug: bool = False) -> None:
73+
"""Transport decorator that invokes EventHook instances and optionally maps exceptions."""
74+
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:
9083
self._transport = transport
9184
self._hooks = hooks
9285
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
9393

9494
def handle_request(self, request: httpx.Request) -> httpx.Response:
9595
_fire_hooks(self._hooks, "on_request", request, debug=self._debug)
9696
try:
9797
response = self._transport.handle_request(request)
9898
except Exception as exc:
9999
_fire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
100+
self._map_error(exc)
100101
raise
101102
_fire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
102103
return response
@@ -107,6 +108,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
107108
response = await self._transport.handle_async_request(request)
108109
except Exception as exc:
109110
await _afire_hooks(self._hooks, "on_error", request, exc, debug=self._debug)
111+
self._map_error(exc)
110112
raise
111113
await _afire_hooks(self._hooks, "on_response", request, response, debug=self._debug)
112114
return response
@@ -116,35 +118,3 @@ def close(self) -> None:
116118

117119
async def aclose(self) -> None:
118120
await self._transport.aclose()
119-
120-
121-
class _ErrorMapperTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
122-
"""Translates exceptions via an error_mapper callback for downstream SDKs."""
123-
124-
def __init__(self, transport, mapper: Callable[[Exception], Exception]) -> None:
125-
self._transport = transport
126-
self._mapper = mapper
127-
128-
def handle_request(self, request: httpx.Request) -> httpx.Response:
129-
try:
130-
return self._transport.handle_request(request)
131-
except Exception as exc:
132-
mapped = self._mapper(exc)
133-
if mapped is not exc:
134-
raise mapped from exc
135-
raise
136-
137-
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
138-
try:
139-
return await self._transport.handle_async_request(request)
140-
except Exception as exc:
141-
mapped = self._mapper(exc)
142-
if mapped is not exc:
143-
raise mapped from exc
144-
raise
145-
146-
def close(self) -> None:
147-
self._transport.close()
148-
149-
async def aclose(self) -> None:
150-
await self._transport.aclose()

ionq_core/_pagination.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919

2020

2121
def _paginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> Iterator[Job]:
22-
"""Generic sync paginator - follows cursors until exhausted."""
2322
kwargs["next_"] = UNSET
2423
while True:
2524
response = fetch(*args, **kwargs)
@@ -33,7 +32,6 @@ def _paginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any)
3332

3433

3534
async def _apaginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> AsyncIterator[Job]:
36-
"""Generic async paginator - follows cursors until exhausted."""
3735
kwargs["next_"] = UNSET
3836
while True:
3937
response = await fetch(*args, **kwargs)
@@ -56,8 +54,7 @@ def iter_jobs(
5654
submitter_id: str | Unset = UNSET,
5755
limit: int | Unset = UNSET,
5856
) -> Iterator[Job]:
59-
"""Iterate over all jobs, automatically following pagination cursors."""
60-
yield from _paginate(
57+
return _paginate(
6158
get_jobs.sync,
6259
"jobs",
6360
client=client,
@@ -69,7 +66,7 @@ def iter_jobs(
6966
)
7067

7168

72-
async def aiter_jobs(
69+
def aiter_jobs(
7370
client: AuthenticatedClient,
7471
*,
7572
status: JobStatus | Unset = UNSET,
@@ -78,8 +75,7 @@ async def aiter_jobs(
7875
submitter_id: str | Unset = UNSET,
7976
limit: int | Unset = UNSET,
8077
) -> AsyncIterator[Job]:
81-
"""Async iterate over all jobs, automatically following pagination cursors."""
82-
async for job in _apaginate(
78+
return _apaginate(
8379
get_jobs.asyncio,
8480
"jobs",
8581
client=client,
@@ -88,8 +84,7 @@ async def aiter_jobs(
8884
session_id=session_id,
8985
submitter_id=submitter_id,
9086
limit=limit,
91-
):
92-
yield job
87+
)
9388

9489

9590
def iter_session_jobs(
@@ -101,8 +96,7 @@ def iter_session_jobs(
10196
submitter_id: str | Unset = UNSET,
10297
limit: int | Unset = UNSET,
10398
) -> Iterator[Job]:
104-
"""Iterate over all jobs in a session, automatically following pagination cursors."""
105-
yield from _paginate(
99+
return _paginate(
106100
get_session_jobs.sync,
107101
"session jobs",
108102
session_id,
@@ -114,7 +108,7 @@ def iter_session_jobs(
114108
)
115109

116110

117-
async def aiter_session_jobs(
111+
def aiter_session_jobs(
118112
client: AuthenticatedClient,
119113
session_id: str,
120114
*,
@@ -123,8 +117,7 @@ async def aiter_session_jobs(
123117
submitter_id: str | Unset = UNSET,
124118
limit: int | Unset = UNSET,
125119
) -> AsyncIterator[Job]:
126-
"""Async iterate over all jobs in a session, automatically following pagination cursors."""
127-
async for job in _apaginate(
120+
return _apaginate(
128121
get_session_jobs.asyncio,
129122
"session jobs",
130123
session_id,
@@ -133,5 +126,4 @@ async def aiter_session_jobs(
133126
target=target,
134127
submitter_id=submitter_id,
135128
limit=limit,
136-
):
137-
yield job
129+
)

ionq_core/_polling.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ def __init__(self, job_id: str, failure: object) -> None:
4343

4444

4545
def _check_terminal(job: GetJobResponse, job_id: str, raise_on_failure: bool) -> bool:
46-
"""Return True if the job reached a terminal state, raising on failure if configured."""
4746
if job.status not in _TERMINAL:
4847
return False
4948
if raise_on_failure and job.status == "failed":
@@ -70,7 +69,6 @@ def wait_for_job(
7069
"""
7170
deadline = time.monotonic() + timeout
7271
interval = poll_interval
73-
7472
while True:
7573
job = get_job.sync(uuid=job_id, client=client)
7674
if job is None:
@@ -95,7 +93,6 @@ async def async_wait_for_job(
9593
"""Async version of wait_for_job."""
9694
deadline = time.monotonic() + timeout
9795
interval = poll_interval
98-
9996
while True:
10097
job = await get_job.asyncio(uuid=job_id, client=client)
10198
if job is None:

ionq_core/_session.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,16 @@ def session_id(self) -> str | None:
5252
return self._session_id
5353

5454
def _build_settings(self) -> SessionSettingsRequest | Unset:
55-
kwargs: dict = {}
55+
kw: dict = {}
5656
if self._max_jobs is not None:
57-
kwargs["job_count_limit"] = self._max_jobs
57+
kw["job_count_limit"] = self._max_jobs
5858
if self._max_time is not None:
59-
kwargs["duration_limit_min"] = self._max_time
59+
kw["duration_limit_min"] = self._max_time
6060
if self._max_cost is not None:
61-
kwargs["cost_limit"] = SessionCostLimit(unit="usd", value=self._max_cost)
62-
return SessionSettingsRequest(**kwargs) if kwargs else UNSET
61+
kw["cost_limit"] = SessionCostLimit(unit="usd", value=self._max_cost)
62+
return SessionSettingsRequest(**kw) if kw else UNSET
6363

6464
def open(self) -> None:
65-
"""Create a new session on the backend."""
6665
if self._session_id is not None:
6766
raise IonQError("Session already open")
6867
body = CreateSessionRequest(backend=self._backend, settings=self._build_settings())
@@ -83,7 +82,6 @@ def close(self) -> None:
8382
logger.warning("Failed to end session %s", self._session_id, exc_info=True)
8483

8584
def status(self) -> str:
86-
"""Query current session status."""
8785
if self._session_id is None:
8886
raise IonQError("No session ID; call open() first")
8987
session = get_session.sync(session_id=self._session_id, client=self._client)
@@ -92,7 +90,6 @@ def status(self) -> str:
9290
return session.status
9391

9492
async def async_open(self) -> None:
95-
"""Create a new session on the backend (async)."""
9693
if self._session_id is not None:
9794
raise IonQError("Session already open")
9895
body = CreateSessionRequest(backend=self._backend, settings=self._build_settings())
@@ -113,7 +110,6 @@ async def async_close(self) -> None:
113110
logger.warning("Failed to end session %s", self._session_id, exc_info=True)
114111

115112
async def async_status(self) -> str:
116-
"""Query current session status (async)."""
117113
if self._session_id is None:
118114
raise IonQError("No session ID; call open() first")
119115
session = await get_session.asyncio(session_id=self._session_id, client=self._client)

ionq_core/_transport.py

Lines changed: 15 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Transport layer: retry via httpx-retries, error raising for IonQ API responses."""
22

3-
from typing import Protocol, runtime_checkable
4-
53
import httpx
64
from httpx_retries import Retry, RetryTransport
75

@@ -11,43 +9,23 @@
119
DEFAULT_MAX_RETRIES = 2
1210

1311

14-
@runtime_checkable
15-
class DualTransport(Protocol):
16-
"""A transport that supports both sync and async request handling."""
17-
18-
def handle_request(self, request: httpx.Request) -> httpx.Response: ...
19-
async def handle_async_request(self, request: httpx.Request) -> httpx.Response: ...
20-
def close(self) -> None: ...
21-
async def aclose(self) -> None: ...
22-
23-
2412
def _raise_for_response(response: httpx.Response) -> None:
25-
if response.status_code < 400:
26-
return
2713
try:
2814
body: dict | str | None = response.json()
2915
except Exception:
30-
text = response.text
31-
body = text[:500] if text else None
16+
body = (response.text or "")[:500] or None
3217
message = body.get("message") or body.get("error") if isinstance(body, dict) else None
33-
header = response.headers.get("retry-after")
3418
try:
35-
retry_after = max(0.0, float(header)) if header is not None else None
36-
except ValueError:
19+
retry_after = max(0.0, float(response.headers["retry-after"]))
20+
except (KeyError, ValueError):
3721
retry_after = None
38-
raise_for_status(
39-
response.status_code,
40-
body,
41-
retry_after,
42-
message,
43-
request_id=response.headers.get("x-request-id"),
44-
)
22+
raise_for_status(response.status_code, body, retry_after, message, request_id=response.headers.get("x-request-id"))
4523

4624

4725
class ErrorRaisingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
4826
"""Wraps a transport to raise structured IonQ exceptions on error responses."""
4927

50-
def __init__(self, transport: DualTransport) -> None:
28+
def __init__(self, transport) -> None:
5129
self._transport = transport
5230

5331
def handle_request(self, request: httpx.Request) -> httpx.Response:
@@ -57,7 +35,9 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
5735
raise APITimeoutError(str(exc)) from exc
5836
except httpx.HTTPError as exc:
5937
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
60-
_raise_for_response(response)
38+
if response.status_code >= 400:
39+
response.read()
40+
_raise_for_response(response)
6141
return response
6242

6343
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
@@ -67,7 +47,9 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
6747
raise APITimeoutError(str(exc)) from exc
6848
except httpx.HTTPError as exc:
6949
raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc
70-
_raise_for_response(response)
50+
if response.status_code >= 400:
51+
await response.aread()
52+
_raise_for_response(response)
7153
return response
7254

7355
def close(self) -> None:
@@ -77,25 +59,14 @@ async def aclose(self) -> None:
7759
await self._transport.aclose()
7860

7961

80-
def build_retry(
62+
def build_transport(
8163
max_retries: int = DEFAULT_MAX_RETRIES,
8264
retryable_status_codes: frozenset[int] = RETRYABLE_STATUS_CODES,
83-
) -> Retry:
84-
"""Build a Retry policy matching IonQ conventions."""
85-
return Retry(
65+
) -> ErrorRaisingTransport:
66+
return ErrorRaisingTransport(RetryTransport(retry=Retry(
8667
total=max_retries,
8768
backoff_factor=0.5,
8869
backoff_jitter=0.5,
8970
max_backoff_wait=60.0,
9071
status_forcelist=retryable_status_codes,
91-
allowed_methods={"GET", "HEAD", "PUT", "DELETE", "OPTIONS"},
92-
respect_retry_after_header=True,
93-
)
94-
95-
96-
def build_transport(
97-
max_retries: int = DEFAULT_MAX_RETRIES,
98-
retryable_status_codes: frozenset[int] = RETRYABLE_STATUS_CODES,
99-
) -> ErrorRaisingTransport:
100-
"""Build a transport with retry and error raising (handles both sync and async)."""
101-
return ErrorRaisingTransport(RetryTransport(retry=build_retry(max_retries, retryable_status_codes)))
72+
)))

0 commit comments

Comments
 (0)