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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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