alt-stack-http-client-httpx 1.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alt_stack_http_client_httpx/__init__.py +779 -0
- alt_stack_http_client_httpx/py.typed +0 -0
- alt_stack_http_client_httpx-1.6.2.dist-info/METADATA +73 -0
- alt_stack_http_client_httpx-1.6.2.dist-info/RECORD +7 -0
- alt_stack_http_client_httpx-1.6.2.dist-info/WHEEL +5 -0
- alt_stack_http_client_httpx-1.6.2.dist-info/licenses/LICENSE +21 -0
- alt_stack_http_client_httpx-1.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
"""Asyncio HTTP client for generated ``Request`` / ``Response`` route maps.
|
|
2
|
+
|
|
3
|
+
This is the Python counterpart of ``@alt-stack/http-client-core`` and
|
|
4
|
+
``@alt-stack/http-client-fetch``, published as ``alt-stack-http-client-httpx``. ``BaseApiClient``
|
|
5
|
+
validates and encodes request inputs with the Pydantic models found in the generated
|
|
6
|
+
``Request`` map, sends the request through a pluggable asyncio ``Transport``, and
|
|
7
|
+
validates the response body with the model found in the generated ``Response`` map.
|
|
8
|
+
|
|
9
|
+
Generated SDK modules subclass ``BaseApiClient`` as ``ApiClient`` and add one typed
|
|
10
|
+
method per HTTP verb whose ``Literal`` path overloads carry the exact request models and
|
|
11
|
+
``{Method}{Path}Result`` unions for each route.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Generic, Literal, Protocol, TypeVar
|
|
23
|
+
from urllib.parse import quote, urlencode
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
28
|
+
from pydantic_core import ErrorDetails
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("python_pydantic_openapi.client")
|
|
31
|
+
|
|
32
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
|
33
|
+
RouteMap = Mapping[str, Any]
|
|
34
|
+
"""``path -> METHOD -> request part or status code -> model class``.
|
|
35
|
+
|
|
36
|
+
Typed as ``Mapping[str, Any]`` because the generated maps are closed ``TypedDict``
|
|
37
|
+
shapes, which are not assignable to a nested ``Mapping`` type.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
|
41
|
+
_PATH_PARAM_PATTERN = re.compile(r"\{([^}]+)\}")
|
|
42
|
+
_MAX_BACKOFF_SECONDS = 30.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ============================================================================
|
|
46
|
+
# Transport
|
|
47
|
+
# ============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class HttpRequest:
|
|
52
|
+
"""Wire-level request handed to a ``Transport``."""
|
|
53
|
+
|
|
54
|
+
method: str
|
|
55
|
+
url: str
|
|
56
|
+
headers: Mapping[str, str]
|
|
57
|
+
body: str | None = None
|
|
58
|
+
timeout: float | None = None
|
|
59
|
+
"""Timeout in seconds, or ``None`` for the transport default."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class HttpResponse:
|
|
64
|
+
"""Wire-level response returned by a ``Transport``.
|
|
65
|
+
|
|
66
|
+
``data`` is the decoded body: parsed JSON when the response is JSON, text otherwise,
|
|
67
|
+
``None`` for an empty body. ``raw`` is the transport's native response object.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
status: int
|
|
71
|
+
reason: str
|
|
72
|
+
data: Any
|
|
73
|
+
raw: Any = None
|
|
74
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Transport(Protocol):
|
|
78
|
+
"""Anything that can send an ``HttpRequest`` on the event loop."""
|
|
79
|
+
|
|
80
|
+
async def send(self, request: HttpRequest) -> HttpResponse: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ============================================================================
|
|
84
|
+
# Errors
|
|
85
|
+
# ============================================================================
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ApiClientError(Exception):
|
|
89
|
+
"""Base error for client failures."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
message: str,
|
|
94
|
+
*,
|
|
95
|
+
endpoint: str | None = None,
|
|
96
|
+
method: str | None = None,
|
|
97
|
+
cause: object = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
super().__init__(message)
|
|
100
|
+
self.message = message
|
|
101
|
+
self.endpoint = endpoint
|
|
102
|
+
self.method = method
|
|
103
|
+
self.cause = cause
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class UnexpectedApiClientError(ApiClientError):
|
|
107
|
+
"""Transport failure, undocumented error status, or unparseable response."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
code: int | None = None,
|
|
114
|
+
endpoint: str | None = None,
|
|
115
|
+
method: str | None = None,
|
|
116
|
+
cause: object = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(message, endpoint=endpoint, method=method, cause=cause)
|
|
119
|
+
self.code = code
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ApiValidationError(ApiClientError):
|
|
123
|
+
"""Request input did not satisfy the generated model for its route."""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
message: str,
|
|
128
|
+
errors: object,
|
|
129
|
+
*,
|
|
130
|
+
endpoint: str | None = None,
|
|
131
|
+
method: str | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
super().__init__(message, endpoint=endpoint, method=method)
|
|
134
|
+
self.errors = errors
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ApiTimeoutError(ApiClientError):
|
|
138
|
+
"""The transport did not complete within the requested timeout."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
timeout: float | None,
|
|
143
|
+
*,
|
|
144
|
+
endpoint: str | None = None,
|
|
145
|
+
method: str | None = None,
|
|
146
|
+
cause: object = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
super().__init__(
|
|
149
|
+
f"Request timeout after {timeout}s", endpoint=endpoint, method=method, cause=cause
|
|
150
|
+
)
|
|
151
|
+
self.timeout = timeout
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ============================================================================
|
|
155
|
+
# Results
|
|
156
|
+
# ============================================================================
|
|
157
|
+
|
|
158
|
+
CodeT = TypeVar("CodeT", bound=str)
|
|
159
|
+
BodyT = TypeVar("BodyT")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class ApiSuccess(Generic[CodeT, BodyT]):
|
|
164
|
+
"""A 2xx response. ``body`` is the validated model when the status is documented."""
|
|
165
|
+
|
|
166
|
+
code: CodeT
|
|
167
|
+
body: BodyT
|
|
168
|
+
raw: Any = None
|
|
169
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
170
|
+
success: Literal[True] = True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class ApiFailure(Generic[CodeT, BodyT]):
|
|
175
|
+
"""A documented non-2xx response with its validated error body."""
|
|
176
|
+
|
|
177
|
+
code: CodeT
|
|
178
|
+
error: BodyT
|
|
179
|
+
raw: Any = None
|
|
180
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
181
|
+
success: Literal[False] = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class ApiUnexpectedError:
|
|
186
|
+
"""A non-2xx status that is not documented, or a response that failed validation."""
|
|
187
|
+
|
|
188
|
+
code: int
|
|
189
|
+
error: UnexpectedApiClientError
|
|
190
|
+
raw: Any = None
|
|
191
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
192
|
+
success: Literal[False] = False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
ApiResult = ApiSuccess[str, Any] | ApiFailure[str, Any] | ApiUnexpectedError
|
|
196
|
+
"""Untyped result returned by ``BaseApiClient.request``."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ============================================================================
|
|
200
|
+
# Hooks and options
|
|
201
|
+
# ============================================================================
|
|
202
|
+
|
|
203
|
+
ValidationKind = Literal["request", "response"]
|
|
204
|
+
ValidationLocation = Literal["params", "query", "body", "response"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class ValidationErrorContext:
|
|
209
|
+
"""Passed to ``on_validation_error`` whenever a Pydantic model rejects data."""
|
|
210
|
+
|
|
211
|
+
kind: ValidationKind
|
|
212
|
+
location: ValidationLocation
|
|
213
|
+
endpoint: str
|
|
214
|
+
method: str
|
|
215
|
+
message: str
|
|
216
|
+
data: Any
|
|
217
|
+
errors: list[ErrorDetails]
|
|
218
|
+
status: int | None = None
|
|
219
|
+
status_code: str | None = None
|
|
220
|
+
reason: str | None = None
|
|
221
|
+
raw: Any = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
ValidationErrorHandler = Callable[[ValidationErrorContext], None]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class RetryContext:
|
|
229
|
+
"""Passed to ``should_retry``; exactly one of ``error`` / ``response`` is set."""
|
|
230
|
+
|
|
231
|
+
attempt: int
|
|
232
|
+
error: BaseException | None = None
|
|
233
|
+
response: HttpResponse | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
ShouldRetry = Callable[[RetryContext], bool]
|
|
237
|
+
Backoff = Callable[[int], float]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def exponential_backoff(attempt: int, base_delay: float = 1.0) -> float:
|
|
241
|
+
"""Seconds to wait before retry ``attempt`` (0-indexed), capped at 30 seconds."""
|
|
242
|
+
return min(base_delay * (2**attempt), _MAX_BACKOFF_SECONDS)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class RequestOptions:
|
|
247
|
+
"""Per-call transport options; route inputs are separate keyword arguments."""
|
|
248
|
+
|
|
249
|
+
headers: Mapping[str, Any] | None = None
|
|
250
|
+
timeout: float | None = None
|
|
251
|
+
retries: int = 0
|
|
252
|
+
should_retry: ShouldRetry | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ============================================================================
|
|
256
|
+
# Client
|
|
257
|
+
# ============================================================================
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class BaseApiClient:
|
|
261
|
+
"""Transport-agnostic client over generated ``Request`` / ``Response`` maps.
|
|
262
|
+
|
|
263
|
+
Use the generated ``ApiClient`` subclass for per-route static typing; use this class
|
|
264
|
+
directly when you only have the maps and want runtime validation.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
base_url: str,
|
|
270
|
+
*,
|
|
271
|
+
transport: Transport,
|
|
272
|
+
request_map: RouteMap,
|
|
273
|
+
response_map: RouteMap,
|
|
274
|
+
headers: Mapping[str, Any] | None = None,
|
|
275
|
+
on_validation_error: ValidationErrorHandler | None = None,
|
|
276
|
+
backoff: Backoff = exponential_backoff,
|
|
277
|
+
) -> None:
|
|
278
|
+
self.base_url = base_url.rstrip("/")
|
|
279
|
+
self.transport = transport
|
|
280
|
+
self.request_map = request_map
|
|
281
|
+
self.response_map = response_map
|
|
282
|
+
self.headers = dict(headers or {})
|
|
283
|
+
self.on_validation_error = on_validation_error
|
|
284
|
+
self.backoff = backoff
|
|
285
|
+
|
|
286
|
+
async def request(
|
|
287
|
+
self,
|
|
288
|
+
method: str,
|
|
289
|
+
path: str,
|
|
290
|
+
*,
|
|
291
|
+
params: object = None,
|
|
292
|
+
query: object = None,
|
|
293
|
+
body: object = None,
|
|
294
|
+
options: RequestOptions | None = None,
|
|
295
|
+
) -> ApiResult:
|
|
296
|
+
"""Validate inputs, send the request with retries, and classify the response.
|
|
297
|
+
|
|
298
|
+
Raises ``ApiValidationError`` for invalid inputs before any I/O. Transport errors
|
|
299
|
+
propagate once retries are exhausted.
|
|
300
|
+
"""
|
|
301
|
+
opts = options or RequestOptions()
|
|
302
|
+
method = method.upper()
|
|
303
|
+
route = _route_entry(self.request_map, path, method)
|
|
304
|
+
|
|
305
|
+
wire_params = self._encode_params(path, method, route.get("params"), params)
|
|
306
|
+
wire_query = self._encode_query(path, method, route.get("query"), query)
|
|
307
|
+
wire_body = (
|
|
308
|
+
None if body is None else self._encode_body(path, method, route.get("body"), body)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
url = self.base_url + _interpolate_path(path, wire_params) + _build_query_string(wire_query)
|
|
312
|
+
headers = _build_headers(self.headers, opts.headers)
|
|
313
|
+
body_text = (
|
|
314
|
+
json.dumps(wire_body) if wire_body is not None and method in _BODY_METHODS else None
|
|
315
|
+
)
|
|
316
|
+
http_request = HttpRequest(
|
|
317
|
+
method=method, url=url, headers=headers, body=body_text, timeout=opts.timeout
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
response = await self._send_with_retries(http_request, path, opts)
|
|
321
|
+
return self._classify_response(response, path, method)
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------ sending
|
|
324
|
+
|
|
325
|
+
async def _send_with_retries(
|
|
326
|
+
self, http_request: HttpRequest, endpoint: str, opts: RequestOptions
|
|
327
|
+
) -> HttpResponse:
|
|
328
|
+
method = http_request.method
|
|
329
|
+
last_error: BaseException | None = None
|
|
330
|
+
last_response: HttpResponse | None = None
|
|
331
|
+
attempt = 0
|
|
332
|
+
|
|
333
|
+
while attempt <= opts.retries:
|
|
334
|
+
try:
|
|
335
|
+
response = await self.transport.send(http_request)
|
|
336
|
+
except ApiValidationError:
|
|
337
|
+
raise
|
|
338
|
+
except Exception as error: # noqa: BLE001 - transport errors are classified below
|
|
339
|
+
last_error = error
|
|
340
|
+
if attempt >= opts.retries:
|
|
341
|
+
break
|
|
342
|
+
if opts.should_retry is not None:
|
|
343
|
+
if not opts.should_retry(RetryContext(attempt=attempt, error=error)):
|
|
344
|
+
break
|
|
345
|
+
elif _is_client_error(error):
|
|
346
|
+
logger.error(
|
|
347
|
+
"HTTP request failed with non-retriable status: %s %s", method, endpoint
|
|
348
|
+
)
|
|
349
|
+
raise
|
|
350
|
+
logger.warning(
|
|
351
|
+
"HTTP retrying after error: %s %s attempt=%d retries=%d error=%r",
|
|
352
|
+
method,
|
|
353
|
+
endpoint,
|
|
354
|
+
attempt,
|
|
355
|
+
opts.retries,
|
|
356
|
+
error,
|
|
357
|
+
)
|
|
358
|
+
await asyncio.sleep(self.backoff(attempt))
|
|
359
|
+
attempt += 1
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
opts.should_retry is not None
|
|
364
|
+
and attempt < opts.retries
|
|
365
|
+
and opts.should_retry(RetryContext(attempt=attempt, response=response))
|
|
366
|
+
):
|
|
367
|
+
logger.warning(
|
|
368
|
+
"HTTP retry requested (response): %s %s status=%d attempt=%d retries=%d",
|
|
369
|
+
method,
|
|
370
|
+
endpoint,
|
|
371
|
+
response.status,
|
|
372
|
+
attempt,
|
|
373
|
+
opts.retries,
|
|
374
|
+
)
|
|
375
|
+
last_response = response
|
|
376
|
+
await asyncio.sleep(self.backoff(attempt))
|
|
377
|
+
attempt += 1
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
return response
|
|
381
|
+
|
|
382
|
+
if last_response is not None:
|
|
383
|
+
return last_response
|
|
384
|
+
|
|
385
|
+
logger.error(
|
|
386
|
+
"HTTP request failed after retries: %s %s attempts=%d error=%r",
|
|
387
|
+
method,
|
|
388
|
+
endpoint,
|
|
389
|
+
attempt,
|
|
390
|
+
last_error,
|
|
391
|
+
)
|
|
392
|
+
if last_error is not None:
|
|
393
|
+
raise last_error
|
|
394
|
+
raise UnexpectedApiClientError(
|
|
395
|
+
"Request failed after retries", endpoint=endpoint, method=method
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# --------------------------------------------------------------- encoding
|
|
399
|
+
|
|
400
|
+
def _encode_params(
|
|
401
|
+
self, endpoint: str, method: str, model: type[BaseModel] | None, params: object
|
|
402
|
+
) -> dict[str, Any]:
|
|
403
|
+
if model is not None:
|
|
404
|
+
return self._validate_and_encode(
|
|
405
|
+
model,
|
|
406
|
+
{} if params is None else params,
|
|
407
|
+
"Path parameters validation failed",
|
|
408
|
+
endpoint,
|
|
409
|
+
method,
|
|
410
|
+
"params",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
provided = _to_mapping(params)
|
|
414
|
+
missing = [name for name in _PATH_PARAM_PATTERN.findall(endpoint) if name not in provided]
|
|
415
|
+
if missing:
|
|
416
|
+
logger.error(
|
|
417
|
+
"HTTP request validation failed: %s %s missing=%s", method, endpoint, missing
|
|
418
|
+
)
|
|
419
|
+
raise ApiValidationError(
|
|
420
|
+
f"Missing required path parameters: {', '.join(missing)}",
|
|
421
|
+
{"missing": missing},
|
|
422
|
+
endpoint=endpoint,
|
|
423
|
+
method=method,
|
|
424
|
+
)
|
|
425
|
+
return dict(provided)
|
|
426
|
+
|
|
427
|
+
def _encode_query(
|
|
428
|
+
self, endpoint: str, method: str, model: type[BaseModel] | None, query: object
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
if model is None:
|
|
431
|
+
return dict(_to_mapping(query))
|
|
432
|
+
return self._validate_and_encode(
|
|
433
|
+
model,
|
|
434
|
+
{} if query is None else query,
|
|
435
|
+
"Query parameters validation failed",
|
|
436
|
+
endpoint,
|
|
437
|
+
method,
|
|
438
|
+
"query",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _encode_body(
|
|
442
|
+
self, endpoint: str, method: str, model: type[BaseModel] | None, body: object
|
|
443
|
+
) -> Any:
|
|
444
|
+
if model is None:
|
|
445
|
+
return (
|
|
446
|
+
body.model_dump(mode="json", by_alias=True) if isinstance(body, BaseModel) else body
|
|
447
|
+
)
|
|
448
|
+
return self._validate_and_encode(
|
|
449
|
+
model, body, "Request body validation failed", endpoint, method, "body"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def _validate_and_encode(
|
|
453
|
+
self,
|
|
454
|
+
model: type[BaseModel],
|
|
455
|
+
data: object,
|
|
456
|
+
message: str,
|
|
457
|
+
endpoint: str,
|
|
458
|
+
method: str,
|
|
459
|
+
location: ValidationLocation,
|
|
460
|
+
) -> Any:
|
|
461
|
+
try:
|
|
462
|
+
parsed = data if isinstance(data, model) else model.model_validate(data)
|
|
463
|
+
except PydanticValidationError as error:
|
|
464
|
+
self._report_validation_error(
|
|
465
|
+
ValidationErrorContext(
|
|
466
|
+
kind="request",
|
|
467
|
+
location=location,
|
|
468
|
+
endpoint=endpoint,
|
|
469
|
+
method=method,
|
|
470
|
+
message=message,
|
|
471
|
+
data=data,
|
|
472
|
+
errors=error.errors(),
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
raise ApiValidationError(
|
|
476
|
+
message, error.errors(), endpoint=endpoint, method=method
|
|
477
|
+
) from error
|
|
478
|
+
return parsed.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------- responses
|
|
481
|
+
|
|
482
|
+
def _classify_response(self, response: HttpResponse, endpoint: str, method: str) -> ApiResult:
|
|
483
|
+
status_code = str(response.status)
|
|
484
|
+
is_success = status_code.startswith("2")
|
|
485
|
+
model = _route_entry(self.response_map, endpoint, method).get(status_code)
|
|
486
|
+
|
|
487
|
+
if model is None:
|
|
488
|
+
if is_success:
|
|
489
|
+
return ApiSuccess(
|
|
490
|
+
code=status_code,
|
|
491
|
+
body=response.data,
|
|
492
|
+
raw=response.raw,
|
|
493
|
+
headers=response.headers,
|
|
494
|
+
)
|
|
495
|
+
logger.error(
|
|
496
|
+
"HTTP error response: %s %s status=%d %s",
|
|
497
|
+
method,
|
|
498
|
+
endpoint,
|
|
499
|
+
response.status,
|
|
500
|
+
response.reason,
|
|
501
|
+
)
|
|
502
|
+
return ApiUnexpectedError(
|
|
503
|
+
code=response.status,
|
|
504
|
+
error=UnexpectedApiClientError(
|
|
505
|
+
f"Unexpected error response: {response.reason}",
|
|
506
|
+
code=response.status,
|
|
507
|
+
endpoint=endpoint,
|
|
508
|
+
method=method,
|
|
509
|
+
cause=response.data,
|
|
510
|
+
),
|
|
511
|
+
raw=response.raw,
|
|
512
|
+
headers=response.headers,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
validated = model.model_validate(response.data)
|
|
517
|
+
except PydanticValidationError as error:
|
|
518
|
+
message = f"Response validation failed for {status_code}"
|
|
519
|
+
self._report_validation_error(
|
|
520
|
+
ValidationErrorContext(
|
|
521
|
+
kind="response",
|
|
522
|
+
location="response",
|
|
523
|
+
endpoint=endpoint,
|
|
524
|
+
method=method,
|
|
525
|
+
message=message,
|
|
526
|
+
data=response.data,
|
|
527
|
+
errors=error.errors(),
|
|
528
|
+
status=response.status,
|
|
529
|
+
status_code=status_code,
|
|
530
|
+
reason=response.reason,
|
|
531
|
+
raw=response.raw,
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
return ApiUnexpectedError(
|
|
535
|
+
code=response.status,
|
|
536
|
+
error=UnexpectedApiClientError(
|
|
537
|
+
f"Response validation failed: {response.reason}",
|
|
538
|
+
code=response.status,
|
|
539
|
+
endpoint=endpoint,
|
|
540
|
+
method=method,
|
|
541
|
+
cause=response.data,
|
|
542
|
+
),
|
|
543
|
+
raw=response.raw,
|
|
544
|
+
headers=response.headers,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if is_success:
|
|
548
|
+
return ApiSuccess(
|
|
549
|
+
code=status_code, body=validated, raw=response.raw, headers=response.headers
|
|
550
|
+
)
|
|
551
|
+
logger.error(
|
|
552
|
+
"HTTP error response: %s %s status=%d %s",
|
|
553
|
+
method,
|
|
554
|
+
endpoint,
|
|
555
|
+
response.status,
|
|
556
|
+
response.reason,
|
|
557
|
+
)
|
|
558
|
+
return ApiFailure(
|
|
559
|
+
code=status_code, error=validated, raw=response.raw, headers=response.headers
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def _report_validation_error(self, context: ValidationErrorContext) -> None:
|
|
563
|
+
logger.error(
|
|
564
|
+
"HTTP validation failed: kind=%s location=%s %s %s status=%s %s",
|
|
565
|
+
context.kind,
|
|
566
|
+
context.location,
|
|
567
|
+
context.method,
|
|
568
|
+
context.endpoint,
|
|
569
|
+
context.status,
|
|
570
|
+
context.message,
|
|
571
|
+
)
|
|
572
|
+
if self.on_validation_error is None:
|
|
573
|
+
return
|
|
574
|
+
try:
|
|
575
|
+
self.on_validation_error(context)
|
|
576
|
+
except Exception: # noqa: BLE001 - observer callbacks never affect control flow
|
|
577
|
+
logger.exception("on_validation_error handler raised")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ============================================================================
|
|
581
|
+
# Helpers
|
|
582
|
+
# ============================================================================
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _route_entry(route_map: RouteMap, path: str, method: str) -> Mapping[str, type[BaseModel]]:
|
|
586
|
+
methods = route_map.get(path)
|
|
587
|
+
entry = methods.get(method) if isinstance(methods, Mapping) else None
|
|
588
|
+
return entry if isinstance(entry, Mapping) else {}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _to_mapping(value: object) -> Mapping[str, Any]:
|
|
592
|
+
if value is None:
|
|
593
|
+
return {}
|
|
594
|
+
if isinstance(value, BaseModel):
|
|
595
|
+
return value.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
596
|
+
if isinstance(value, Mapping):
|
|
597
|
+
return value
|
|
598
|
+
raise TypeError(f"Expected a mapping or Pydantic model, got {type(value).__name__}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _is_client_error(error: BaseException) -> bool:
|
|
602
|
+
return (
|
|
603
|
+
isinstance(error, UnexpectedApiClientError)
|
|
604
|
+
and error.code is not None
|
|
605
|
+
and 400 <= error.code < 500
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _interpolate_path(endpoint: str, params: Mapping[str, Any]) -> str:
|
|
610
|
+
result = endpoint
|
|
611
|
+
for key, value in params.items():
|
|
612
|
+
result = result.replace(f"{{{key}}}", quote(_to_wire_string(value), safe=""))
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _build_query_string(query: Mapping[str, Any]) -> str:
|
|
617
|
+
pairs: list[tuple[str, str]] = []
|
|
618
|
+
for key, value in query.items():
|
|
619
|
+
if value is None:
|
|
620
|
+
continue
|
|
621
|
+
if isinstance(value, (list, tuple)):
|
|
622
|
+
pairs.extend((key, _to_wire_string(item)) for item in value if item is not None)
|
|
623
|
+
else:
|
|
624
|
+
pairs.append((key, _to_wire_string(value)))
|
|
625
|
+
encoded = urlencode(pairs)
|
|
626
|
+
return f"?{encoded}" if encoded else ""
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _build_headers(
|
|
630
|
+
client_headers: Mapping[str, Any], request_headers: Mapping[str, Any] | None
|
|
631
|
+
) -> dict[str, str]:
|
|
632
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
633
|
+
for header_set in (client_headers, request_headers or {}):
|
|
634
|
+
for key, value in header_set.items():
|
|
635
|
+
if value is not None:
|
|
636
|
+
headers[key] = _to_wire_string(value)
|
|
637
|
+
return headers
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _to_wire_string(value: Any) -> str:
|
|
641
|
+
if isinstance(value, bool):
|
|
642
|
+
return "true" if value else "false"
|
|
643
|
+
if isinstance(value, str):
|
|
644
|
+
return value
|
|
645
|
+
if isinstance(value, (dict, list)):
|
|
646
|
+
return json.dumps(value)
|
|
647
|
+
return str(value)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ============================================================================
|
|
651
|
+
# httpx transport
|
|
652
|
+
# ============================================================================
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class HttpxTransport:
|
|
656
|
+
"""``Transport`` backed by ``httpx.AsyncClient``.
|
|
657
|
+
|
|
658
|
+
Pass an existing ``httpx.AsyncClient`` to share connection pools or configure
|
|
659
|
+
defaults; otherwise one is created and owned here.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
|
663
|
+
self._owns_client = client is None
|
|
664
|
+
self._client = client if client is not None else httpx.AsyncClient()
|
|
665
|
+
|
|
666
|
+
async def send(self, request: HttpRequest) -> HttpResponse:
|
|
667
|
+
timeout = request.timeout if request.timeout is not None else httpx.USE_CLIENT_DEFAULT
|
|
668
|
+
try:
|
|
669
|
+
response = await self._client.request(
|
|
670
|
+
request.method,
|
|
671
|
+
request.url,
|
|
672
|
+
headers=dict(request.headers),
|
|
673
|
+
content=request.body,
|
|
674
|
+
timeout=timeout,
|
|
675
|
+
)
|
|
676
|
+
except httpx.TimeoutException as error:
|
|
677
|
+
raise ApiTimeoutError(
|
|
678
|
+
request.timeout, endpoint=request.url, method=request.method, cause=error
|
|
679
|
+
) from error
|
|
680
|
+
except httpx.HTTPError as error:
|
|
681
|
+
raise UnexpectedApiClientError(
|
|
682
|
+
f"Network error: {error}", endpoint=request.url, method=request.method, cause=error
|
|
683
|
+
) from error
|
|
684
|
+
|
|
685
|
+
return HttpResponse(
|
|
686
|
+
status=response.status_code,
|
|
687
|
+
reason=response.reason_phrase,
|
|
688
|
+
data=_parse_response_data(response),
|
|
689
|
+
raw=response,
|
|
690
|
+
headers=dict(response.headers),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
async def aclose(self) -> None:
|
|
694
|
+
if self._owns_client:
|
|
695
|
+
await self._client.aclose()
|
|
696
|
+
|
|
697
|
+
async def __aenter__(self) -> HttpxTransport:
|
|
698
|
+
return self
|
|
699
|
+
|
|
700
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
701
|
+
await self.aclose()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class HttpxApiClient(BaseApiClient):
|
|
705
|
+
"""``BaseApiClient`` over ``httpx``; the primary client for generated SDKs.
|
|
706
|
+
|
|
707
|
+
Owns an ``HttpxTransport``. Pass ``httpx_client`` to share connection pools or configure
|
|
708
|
+
``httpx.AsyncClient`` defaults; otherwise one is created and closed by ``aclose``.
|
|
709
|
+
"""
|
|
710
|
+
|
|
711
|
+
def __init__(
|
|
712
|
+
self,
|
|
713
|
+
base_url: str,
|
|
714
|
+
*,
|
|
715
|
+
request_map: RouteMap,
|
|
716
|
+
response_map: RouteMap,
|
|
717
|
+
headers: Mapping[str, Any] | None = None,
|
|
718
|
+
on_validation_error: ValidationErrorHandler | None = None,
|
|
719
|
+
backoff: Backoff = exponential_backoff,
|
|
720
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
721
|
+
) -> None:
|
|
722
|
+
self._httpx_transport = HttpxTransport(httpx_client)
|
|
723
|
+
super().__init__(
|
|
724
|
+
base_url,
|
|
725
|
+
transport=self._httpx_transport,
|
|
726
|
+
request_map=request_map,
|
|
727
|
+
response_map=response_map,
|
|
728
|
+
headers=headers,
|
|
729
|
+
on_validation_error=on_validation_error,
|
|
730
|
+
backoff=backoff,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
async def aclose(self) -> None:
|
|
734
|
+
await self._httpx_transport.aclose()
|
|
735
|
+
|
|
736
|
+
async def __aenter__(self) -> HttpxApiClient:
|
|
737
|
+
return self
|
|
738
|
+
|
|
739
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
740
|
+
await self.aclose()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _parse_response_data(response: httpx.Response) -> Any:
|
|
744
|
+
if response.headers.get("content-length", "").strip() == "0":
|
|
745
|
+
return None
|
|
746
|
+
content_type = response.headers.get("content-type", "")
|
|
747
|
+
if "application/json" in content_type:
|
|
748
|
+
try:
|
|
749
|
+
return response.json()
|
|
750
|
+
except ValueError:
|
|
751
|
+
return response.text
|
|
752
|
+
return response.text
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
__all__ = [
|
|
756
|
+
"ApiClientError",
|
|
757
|
+
"ApiFailure",
|
|
758
|
+
"ApiResult",
|
|
759
|
+
"ApiSuccess",
|
|
760
|
+
"ApiTimeoutError",
|
|
761
|
+
"ApiUnexpectedError",
|
|
762
|
+
"ApiValidationError",
|
|
763
|
+
"Backoff",
|
|
764
|
+
"BaseApiClient",
|
|
765
|
+
"HttpMethod",
|
|
766
|
+
"HttpRequest",
|
|
767
|
+
"HttpResponse",
|
|
768
|
+
"HttpxApiClient",
|
|
769
|
+
"HttpxTransport",
|
|
770
|
+
"RequestOptions",
|
|
771
|
+
"RetryContext",
|
|
772
|
+
"RouteMap",
|
|
773
|
+
"ShouldRetry",
|
|
774
|
+
"Transport",
|
|
775
|
+
"UnexpectedApiClientError",
|
|
776
|
+
"ValidationErrorContext",
|
|
777
|
+
"ValidationErrorHandler",
|
|
778
|
+
"exponential_backoff",
|
|
779
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alt-stack-http-client-httpx
|
|
3
|
+
Version: 1.6.2
|
|
4
|
+
Summary: Asyncio HTTP client for alt-stack generated Pydantic SDKs, backed by httpx
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/anthonyaltieri/alt-stack
|
|
7
|
+
Project-URL: Repository, https://github.com/anthonyaltieri/alt-stack
|
|
8
|
+
Project-URL: Issues, https://github.com/anthonyaltieri/alt-stack/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pydantic>=2.7.0
|
|
17
|
+
Requires-Dist: httpx>=0.27.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pyrefly>=1.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ty>=0.0.78; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# `alt-stack-http-client-httpx`
|
|
26
|
+
|
|
27
|
+
Asyncio HTTP client for Python SDKs generated by `alt-stack-openapi-pydantic`. It is the Python counterpart of `@alt-stack/http-client-core` and `@alt-stack/http-client-fetch`: request inputs are validated and encoded with the SDK's `Request` map, responses are validated with its `Response` map, and results come back as a small discriminated union.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python -m pip install alt-stack-http-client-httpx
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Generated SDK modules import `alt_stack_http_client_httpx` at runtime, so SDK consumers install this package. The generator itself is a development-time dependency.
|
|
36
|
+
|
|
37
|
+
## Use a generated SDK
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from alt_stack_http_client_httpx import ApiFailure, ApiSuccess
|
|
41
|
+
|
|
42
|
+
from generated_types import GetUsersIdParams, HttpxApiClient, Request, Response
|
|
43
|
+
|
|
44
|
+
async with HttpxApiClient(
|
|
45
|
+
"https://api.example.com", request_map=Request, response_map=Response
|
|
46
|
+
) as client:
|
|
47
|
+
result = await client.get("/users/{id}", params=GetUsersIdParams(id="u_1"))
|
|
48
|
+
|
|
49
|
+
if isinstance(result, ApiSuccess):
|
|
50
|
+
result.body # User
|
|
51
|
+
elif isinstance(result, ApiFailure):
|
|
52
|
+
result.error # documented error model
|
|
53
|
+
else:
|
|
54
|
+
result.error # UnexpectedApiClientError
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Importing `HttpxApiClient` from the generated module gives one typed method per route. Importing it from `alt_stack_http_client_httpx` accepts the same arguments and exposes the untyped `request(method, path, ...)` API with runtime validation.
|
|
58
|
+
|
|
59
|
+
## Bring your own transport
|
|
60
|
+
|
|
61
|
+
`BaseApiClient` sends through the `Transport` protocol, a single `async def send(request: HttpRequest) -> HttpResponse`. Implement it over any asyncio HTTP library and pass it to the generated `ApiClient(base_url, transport=..., request_map=Request, response_map=Response)`.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv run --project packages/python-http-client-httpx --extra dev pytest packages/python-http-client-httpx/tests
|
|
67
|
+
uv run --project packages/python-http-client-httpx --extra dev ruff check packages/python-http-client-httpx
|
|
68
|
+
uv run --project packages/python-http-client-httpx --extra dev ty check packages/python-http-client-httpx
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
- [Python/Pydantic API Documentation](../../apps/docs/docs/codegen/api/pydantic-openapi.md)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
alt_stack_http_client_httpx/__init__.py,sha256=U3BUMj4dUm6nHZotR_zrms-da5X-xC8Oqhx04kQFPTo,25774
|
|
2
|
+
alt_stack_http_client_httpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
alt_stack_http_client_httpx-1.6.2.dist-info/licenses/LICENSE,sha256=OvI3tgOAzCyCFOjcQ42mFXk6SKQTicTDrUgbqofOXGY,1072
|
|
4
|
+
alt_stack_http_client_httpx-1.6.2.dist-info/METADATA,sha256=IeAs6NztJjG_Nj6KS13CJIsvf-EmvCtqNP20QNBtBr0,3138
|
|
5
|
+
alt_stack_http_client_httpx-1.6.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
alt_stack_http_client_httpx-1.6.2.dist-info/top_level.txt,sha256=PZXXm6msFY_RF6sU7i0l0xbP8m1Qv0q40mi2eLVzdP4,28
|
|
7
|
+
alt_stack_http_client_httpx-1.6.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Anthony Altieri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
alt_stack_http_client_httpx
|