clientwright 0.1.0__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.
Files changed (102) hide show
  1. clientwright/__init__.py +179 -0
  2. clientwright/__version__.py +1 -0
  3. clientwright/adapters/__init__.py +3 -0
  4. clientwright/adapters/_httpx_shared.py +831 -0
  5. clientwright/adapters/_lazy.py +30 -0
  6. clientwright/adapters/aiohttp/__init__.py +45 -0
  7. clientwright/adapters/aiohttp/_imports.py +30 -0
  8. clientwright/adapters/aiohttp/adapter.py +236 -0
  9. clientwright/adapters/aiohttp/capabilities.py +81 -0
  10. clientwright/adapters/aiohttp/classify.py +59 -0
  11. clientwright/adapters/aiohttp/errors.py +57 -0
  12. clientwright/adapters/aiohttp/middleware.py +103 -0
  13. clientwright/adapters/aiohttp/normalize.py +64 -0
  14. clientwright/adapters/aiohttp/options.py +16 -0
  15. clientwright/adapters/aiohttp/trace.py +109 -0
  16. clientwright/adapters/aiohttp/views.py +108 -0
  17. clientwright/adapters/httpx/__init__.py +45 -0
  18. clientwright/adapters/httpx/_imports.py +17 -0
  19. clientwright/adapters/httpx/adapter.py +39 -0
  20. clientwright/adapters/httpx/capabilities.py +9 -0
  21. clientwright/adapters/httpx/classify.py +14 -0
  22. clientwright/adapters/httpx/errors.py +35 -0
  23. clientwright/adapters/httpx/normalize.py +27 -0
  24. clientwright/adapters/httpx/normalize_sync.py +27 -0
  25. clientwright/adapters/httpx/transport.py +40 -0
  26. clientwright/adapters/httpx/views.py +46 -0
  27. clientwright/adapters/httpx2/__init__.py +46 -0
  28. clientwright/adapters/httpx2/_imports.py +18 -0
  29. clientwright/adapters/httpx2/adapter.py +37 -0
  30. clientwright/adapters/httpx2/capabilities.py +9 -0
  31. clientwright/adapters/httpx2/classify.py +14 -0
  32. clientwright/adapters/httpx2/errors.py +36 -0
  33. clientwright/adapters/httpx2/normalize.py +27 -0
  34. clientwright/adapters/httpx2/normalize_sync.py +27 -0
  35. clientwright/adapters/httpx2/transport.py +35 -0
  36. clientwright/adapters/httpx2/views.py +45 -0
  37. clientwright/adapters/observability/__init__.py +26 -0
  38. clientwright/adapters/observability/_metrics/__init__.py +1 -0
  39. clientwright/adapters/observability/_metrics/prometheus.py +200 -0
  40. clientwright/adapters/observability/_tracing/__init__.py +3 -0
  41. clientwright/adapters/observability/_tracing/otel.py +61 -0
  42. clientwright/adapters/requests/__init__.py +45 -0
  43. clientwright/adapters/requests/_imports.py +21 -0
  44. clientwright/adapters/requests/adapter.py +206 -0
  45. clientwright/adapters/requests/capabilities.py +80 -0
  46. clientwright/adapters/requests/classify.py +62 -0
  47. clientwright/adapters/requests/errors.py +40 -0
  48. clientwright/adapters/requests/normalize.py +63 -0
  49. clientwright/adapters/requests/views.py +121 -0
  50. clientwright/adapters/urllib3/__init__.py +48 -0
  51. clientwright/adapters/urllib3/_imports.py +18 -0
  52. clientwright/adapters/urllib3/adapter.py +260 -0
  53. clientwright/adapters/urllib3/capabilities.py +86 -0
  54. clientwright/adapters/urllib3/classify.py +46 -0
  55. clientwright/adapters/urllib3/errors.py +55 -0
  56. clientwright/adapters/urllib3/normalize.py +51 -0
  57. clientwright/adapters/urllib3/views.py +119 -0
  58. clientwright/contrib/__init__.py +3 -0
  59. clientwright/contrib/deadline.py +107 -0
  60. clientwright/contrib/dishka.py +80 -0
  61. clientwright/core/__init__.py +6 -0
  62. clientwright/core/balancer/__init__.py +1 -0
  63. clientwright/core/balancer/policy.py +23 -0
  64. clientwright/core/capabilities.py +156 -0
  65. clientwright/core/config.py +367 -0
  66. clientwright/core/contracts/__init__.py +33 -0
  67. clientwright/core/contracts/adapter.py +62 -0
  68. clientwright/core/contracts/context.py +31 -0
  69. clientwright/core/contracts/message.py +118 -0
  70. clientwright/core/contracts/observability.py +92 -0
  71. clientwright/core/contracts/settings.py +140 -0
  72. clientwright/core/engine/__init__.py +1 -0
  73. clientwright/core/engine/aio.py +246 -0
  74. clientwright/core/engine/base.py +65 -0
  75. clientwright/core/engine/redirects.py +64 -0
  76. clientwright/core/engine/suppress.py +30 -0
  77. clientwright/core/engine/sync.py +241 -0
  78. clientwright/core/errors.py +113 -0
  79. clientwright/core/model.py +138 -0
  80. clientwright/core/native.py +84 -0
  81. clientwright/core/options.py +46 -0
  82. clientwright/core/plan.py +190 -0
  83. clientwright/core/policy/__init__.py +1 -0
  84. clientwright/core/policy/budget.py +76 -0
  85. clientwright/core/policy/circuit.py +169 -0
  86. clientwright/core/policy/concurrency.py +98 -0
  87. clientwright/core/policy/retry.py +80 -0
  88. clientwright/core/policy/timeout.py +64 -0
  89. clientwright/core/registry.py +75 -0
  90. clientwright/core/telemetry/__init__.py +6 -0
  91. clientwright/core/telemetry/emitter.py +163 -0
  92. clientwright/core/telemetry/names.py +61 -0
  93. clientwright/core/telemetry/null.py +89 -0
  94. clientwright/core/telemetry/redaction.py +24 -0
  95. clientwright/core/testing/__init__.py +7 -0
  96. clientwright/core/testing/doubles.py +107 -0
  97. clientwright/core/testing/origin.py +201 -0
  98. clientwright/py.typed +0 -0
  99. clientwright-0.1.0.dist-info/METADATA +210 -0
  100. clientwright-0.1.0.dist-info/RECORD +102 -0
  101. clientwright-0.1.0.dist-info/WHEEL +4 -0
  102. clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,831 @@
1
+ """Shared machinery of the httpx family: httpx and its successor httpx2.
2
+
3
+ SDK-agnostic BY CONSTRUCTION: this module never imports httpx or httpx2 (and
4
+ stays importable without either extra). Each adapter's modules hand their SDK
5
+ namespace in and bind the SDK base classes (byte streams, transports, error
6
+ families) to the logic mixins defined here. This is the ONE sanctioned channel
7
+ for code sharing between the paired adapters; the import-linter independence
8
+ contract still forbids importing a sibling adapter directly.
9
+
10
+ Public names in both adapters are IDENTICAL on purpose: migrating a service is
11
+ one extra and one import path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ssl
17
+ from collections.abc import Callable, Mapping, MutableMapping
18
+ from typing import Any
19
+
20
+ from ..core.capabilities import (
21
+ AdapterCapabilities,
22
+ Capability,
23
+ DurationBoundary,
24
+ SeamGranularity,
25
+ Support,
26
+ )
27
+ from ..core.config import ClientConfig, RedirectMode, is_set, resolve
28
+ from ..core.contracts.adapter import AdapterDeps
29
+ from ..core.engine.aio import AsyncAttemptEngine
30
+ from ..core.engine.sync import SyncAttemptEngine
31
+ from ..core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
32
+ from ..core.model import IDEMPOTENT_METHODS, ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
33
+ from ..core.native import validate_native
34
+ from ..core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
35
+ from ..core.policy.timeout import base_timeouts
36
+ from ..core.telemetry.emitter import ClientTelemetry
37
+
38
+ # Per-call extension keys understood by clientwright (identical across the family).
39
+ ROUTE_EXTENSION = "clientwright_route"
40
+ IDEMPOTENT_EXTENSION = "clientwright_idempotent"
41
+
42
+ # The family defaults every phase to 5 seconds (DEFAULT_TIMEOUT_CONFIG).
43
+ NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts(connect=5.0, read=5.0, write=5.0, pool_acquire=5.0)
44
+
45
+ BODY_HEADERS = ("content-length", "content-type", "transfer-encoding")
46
+
47
+ RESERVED_CLIENT_KEYS: Mapping[str, str] = {
48
+ "transport": "the transport belongs to clientwright; tune it via config or the 'transport' slot",
49
+ "mounts": "mounts are built by clientwright for proxy routing",
50
+ "timeout": "use ClientConfig.timeout",
51
+ "follow_redirects": "redirects are owned by the engine; use ClientConfig.redirects",
52
+ "max_redirects": "use ClientConfig.max_redirects",
53
+ "limits": "use ClientConfig.pool",
54
+ "http2": "use ClientConfig.pool.http2",
55
+ "verify": "use ClientConfig.tls",
56
+ "cert": "use ClientConfig.tls.cert",
57
+ "proxy": "use ClientConfig.proxy",
58
+ "proxies": "use ClientConfig.proxy",
59
+ "base_url": "use ClientConfig.base_url",
60
+ }
61
+
62
+ RESERVED_TRANSPORT_KEYS: Mapping[str, str] = {
63
+ "verify": "use ClientConfig.tls",
64
+ "cert": "use ClientConfig.tls.cert",
65
+ "http2": "use ClientConfig.pool.http2",
66
+ "limits": "use ClientConfig.pool",
67
+ "proxy": "use ClientConfig.proxy",
68
+ "retries": "retries belong to clientwright; use ClientConfig.retry",
69
+ }
70
+
71
+
72
+ def capabilities_for(adapter: str) -> AdapterCapabilities:
73
+ """The family capability record; only the adapter name differs."""
74
+ return AdapterCapabilities(
75
+ adapter=adapter,
76
+ seam="transport",
77
+ granularity=SeamGranularity.HOP,
78
+ boundary=DurationBoundary.HEADERS,
79
+ support={
80
+ Capability.TIMEOUT_TOTAL: Support.EMULATED,
81
+ Capability.TIMEOUT_ATTEMPT: Support.EMULATED,
82
+ Capability.TIMEOUT_CONNECT: Support.NATIVE,
83
+ Capability.TIMEOUT_READ: Support.NATIVE,
84
+ Capability.TIMEOUT_WRITE: Support.NATIVE,
85
+ Capability.TIMEOUT_POOL: Support.NATIVE,
86
+ Capability.DEADLINE_HARD: Support.EMULATED,
87
+ Capability.POOL_LIMIT_TOTAL: Support.NATIVE,
88
+ Capability.POOL_LIMIT_PER_HOST: Support.EMULATED,
89
+ Capability.KEEPALIVE: Support.NATIVE,
90
+ Capability.POOL_METRICS: Support.ABSENT,
91
+ Capability.CONN_METRICS: Support.ABSENT,
92
+ Capability.REDIRECTS_OWNABLE: Support.NATIVE,
93
+ Capability.NATIVE_RETRY_DISABLEABLE: Support.NATIVE,
94
+ Capability.PER_CALL_OPTIONS: Support.NATIVE,
95
+ Capability.RETROFIT: Support.ABSENT,
96
+ Capability.EXACT_NATIVE_TYPE: Support.NATIVE,
97
+ Capability.BALANCER: Support.ABSENT,
98
+ Capability.HTTP2: Support.NATIVE,
99
+ Capability.HTTP3: Support.ABSENT,
100
+ Capability.PROXY: Support.NATIVE,
101
+ },
102
+ emits=frozenset(
103
+ {
104
+ FailureKind.CONNECT_TIMEOUT,
105
+ FailureKind.READ_TIMEOUT,
106
+ FailureKind.WRITE_TIMEOUT,
107
+ FailureKind.POOL_TIMEOUT,
108
+ FailureKind.TOTAL_TIMEOUT,
109
+ FailureKind.CONNECT_ERROR,
110
+ FailureKind.TLS_ERROR,
111
+ FailureKind.PROTOCOL_ERROR,
112
+ FailureKind.DISCONNECTED,
113
+ FailureKind.BODY_ERROR,
114
+ FailureKind.STATUS,
115
+ FailureKind.CANCELLED,
116
+ FailureKind.CIRCUIT_OPEN,
117
+ FailureKind.UNKNOWN,
118
+ }
119
+ ),
120
+ collapses={FailureKind.DNS_ERROR: FailureKind.CONNECT_ERROR},
121
+ notes={
122
+ "deadline_hard": "Hard cancellation on the async client only; the sync client clamps phases (soft).",
123
+ "pool_limit_per_host": "Emulated as a per-origin in-flight semaphore; limits requests, not connections.",
124
+ "dns_error": f"{adapter} wraps DNS failures into ConnectError; they surface as connect_error.",
125
+ "proxy_from_env": "Environment proxies are parsed into mounts; NO_PROXY entries match hosts literally.",
126
+ },
127
+ )
128
+
129
+
130
+ def has_ssl_cause(exc: BaseException) -> bool:
131
+ seen: set[int] = set()
132
+ current: BaseException | None = exc
133
+ while current is not None and id(current) not in seen:
134
+ seen.add(id(current))
135
+ if isinstance(current, (ssl.SSLError, ssl.CertificateError)):
136
+ return True
137
+ current = current.__cause__ or current.__context__
138
+ return False
139
+
140
+
141
+ def classify_family_error(sdk: Any, exc: BaseException) -> FailureKind:
142
+ """Exception -> FailureKind for any SDK exposing the httpx error names."""
143
+ import asyncio # noqa: PLC0415 - stdlib, deferred to keep module import light
144
+
145
+ if isinstance(exc, asyncio.CancelledError):
146
+ return FailureKind.CANCELLED
147
+ if isinstance(exc, sdk.ConnectTimeout):
148
+ return FailureKind.CONNECT_TIMEOUT
149
+ if isinstance(exc, sdk.ReadTimeout):
150
+ return FailureKind.READ_TIMEOUT
151
+ if isinstance(exc, sdk.WriteTimeout):
152
+ return FailureKind.WRITE_TIMEOUT
153
+ if isinstance(exc, sdk.PoolTimeout):
154
+ return FailureKind.POOL_TIMEOUT
155
+ if isinstance(exc, sdk.ConnectError):
156
+ return FailureKind.TLS_ERROR if has_ssl_cause(exc) else FailureKind.CONNECT_ERROR
157
+ if isinstance(exc, (sdk.ReadError, sdk.WriteError, sdk.CloseError)):
158
+ return FailureKind.DISCONNECTED
159
+ if isinstance(exc, sdk.RemoteProtocolError):
160
+ # A premature server close arrives as RemoteProtocolError; that is a
161
+ # disconnect (retryable), not a malformed response.
162
+ return FailureKind.DISCONNECTED if "disconnect" in str(exc).lower() else FailureKind.PROTOCOL_ERROR
163
+ if isinstance(exc, sdk.LocalProtocolError):
164
+ return FailureKind.PROTOCOL_ERROR
165
+ if isinstance(exc, sdk.ProxyError):
166
+ return FailureKind.CONNECT_ERROR
167
+ if isinstance(exc, TimeoutError):
168
+ return FailureKind.TOTAL_TIMEOUT
169
+ return FailureKind.UNKNOWN
170
+
171
+
172
+ def make_error_translator(
173
+ circuit_cls: type[CircuitOpenError],
174
+ deadline_cls: type[DeadlineExceededError],
175
+ redirects_cls: type[TooManyRedirectsError],
176
+ ) -> Callable[[CallError], BaseException]:
177
+ """Build the CallError -> dual-family translator from the bound classes."""
178
+
179
+ def translate(error: CallError) -> BaseException:
180
+ if isinstance(error, CircuitOpenError):
181
+ return circuit_cls(error.key, error.retry_after)
182
+ if isinstance(error, DeadlineExceededError):
183
+ return deadline_cls(error.total)
184
+ if isinstance(error, TooManyRedirectsError):
185
+ return redirects_cls(error.hops)
186
+ return error
187
+
188
+ return translate
189
+
190
+
191
+ def ssl_arguments(config: ClientConfig) -> dict[str, Any]:
192
+ """TLS kwargs for the transport constructor.
193
+
194
+ ``create_ssl_context`` returns early for ``verify=<path>`` and
195
+ ``verify=False``, silently DROPPING the ``cert`` kwarg - so whenever a
196
+ client certificate meets either of those verify modes, the SSLContext is
197
+ baked here (which also surfaces a bad cert path at build time).
198
+ """
199
+ tls = config.tls
200
+ if tls.cert is not None and (tls.ca_bundle is not None or not tls.verify):
201
+ context = ssl.create_default_context(cafile=tls.ca_bundle)
202
+ if not tls.verify and tls.ca_bundle is None:
203
+ context.check_hostname = False
204
+ context.verify_mode = ssl.CERT_NONE
205
+ if isinstance(tls.cert, str):
206
+ context.load_cert_chain(tls.cert)
207
+ else:
208
+ context.load_cert_chain(*tls.cert)
209
+ return {"verify": context}
210
+ kwargs: dict[str, Any] = {"verify": tls.ca_bundle if tls.ca_bundle is not None else tls.verify}
211
+ if tls.cert is not None:
212
+ kwargs["cert"] = tls.cert
213
+ return kwargs
214
+
215
+
216
+ def timeout_dict(base: ResolvedTimeouts) -> dict[str, float | None]:
217
+ return {"connect": base.connect, "read": base.read, "write": base.write, "pool": base.pool_acquire}
218
+
219
+
220
+ def no_proxy_hosts(proxies: dict[str, str]) -> list[str]:
221
+ raw = proxies.get("no", "")
222
+ return [entry.strip() for entry in raw.split(",") if entry.strip() and entry.strip() != "*"]
223
+
224
+
225
+ def host_bypasses_proxy(host: str, no_proxy: tuple[str, ...]) -> bool:
226
+ for entry in no_proxy:
227
+ candidate = entry.lstrip(".")
228
+ if host == candidate or host.endswith("." + candidate):
229
+ return True
230
+ return False
231
+
232
+
233
+ class FamilyRequestView:
234
+ """Mutable view over a family Request; SDK handed in by the adapter."""
235
+
236
+ __slots__ = ("_caller", "_replay_stream", "_request", "_sdk")
237
+
238
+ def __init__(
239
+ self,
240
+ request: Any,
241
+ default_timeout: dict[str, float | None],
242
+ *,
243
+ sdk: Any,
244
+ replay_stream: Callable[[bytes], Any],
245
+ ) -> None:
246
+ self._request = request
247
+ self._sdk = sdk
248
+ self._replay_stream = replay_stream
249
+ # Detected ONCE, before the engine's own apply_timeouts mutates the
250
+ # extension - otherwise every redirect hop would misread the engine's
251
+ # previously planned values as a caller override.
252
+ self._caller = self._detect_caller(request, default_timeout)
253
+
254
+ @staticmethod
255
+ def _detect_caller(request: Any, default_timeout: dict[str, float | None]) -> ResolvedTimeouts | None:
256
+ current = request.extensions.get("timeout")
257
+ if not isinstance(current, dict) or dict(current) == default_timeout:
258
+ return None
259
+ return ResolvedTimeouts(
260
+ connect=current.get("connect"),
261
+ read=current.get("read"),
262
+ write=current.get("write"),
263
+ pool_acquire=current.get("pool"),
264
+ )
265
+
266
+ @property
267
+ def native(self) -> Any:
268
+ return self._request
269
+
270
+ @property
271
+ def info(self) -> RequestInfo:
272
+ request = self._request
273
+ url = str(request.url)
274
+ method = request.method
275
+ route = request.extensions.get(ROUTE_EXTENSION)
276
+ idempotent = bool(request.extensions.get(IDEMPOTENT_EXTENSION, method in IDEMPOTENT_METHODS))
277
+ return RequestInfo(
278
+ method=method,
279
+ origin=origin_of(url),
280
+ url=url,
281
+ route=route if isinstance(route, str) else None,
282
+ idempotent=idempotent,
283
+ )
284
+
285
+ @property
286
+ def headers(self) -> MutableMapping[str, str]:
287
+ return self._request.headers # type: ignore[no-any-return]
288
+
289
+ def caller_timeouts(self) -> ResolvedTimeouts | None:
290
+ return self._caller
291
+
292
+ def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
293
+ self._request.extensions["timeout"] = {
294
+ "connect": timeouts.connect,
295
+ "read": timeouts.read,
296
+ "write": timeouts.write,
297
+ "pool": timeouts.pool_acquire,
298
+ }
299
+
300
+ def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
301
+ """Mutate the ORIGINAL request in place.
302
+
303
+ Identity matters: the client layer holds this very object and later
304
+ does ``response.request = request`` and cookie extraction against its
305
+ URL - a replacement object would leave the final response attributed to
306
+ the pre-redirect URL (cross-origin Set-Cookie would land in the jar
307
+ under the wrong host).
308
+ """
309
+ request = self._request
310
+ target = self._sdk.URL(url)
311
+ request.url = target
312
+ request.headers["host"] = target.netloc.decode("ascii")
313
+ if method is not None:
314
+ request.method = method
315
+ if drop_body:
316
+ for name in BODY_HEADERS:
317
+ request.headers.pop(name, None)
318
+ request.stream = self._replay_stream(b"")
319
+ # Keep .read()/.content consistent with the emptied stream.
320
+ request._content = b""
321
+
322
+
323
+ class FamilyResponseView:
324
+ __slots__ = ("_response",)
325
+
326
+ def __init__(self, response: Any) -> None:
327
+ self._response = response
328
+
329
+ @property
330
+ def native(self) -> Any:
331
+ return self._response
332
+
333
+ @property
334
+ def status_code(self) -> int:
335
+ return self._response.status_code # type: ignore[no-any-return]
336
+
337
+ def header(self, name: str) -> str | None:
338
+ value = self._response.headers.get(name)
339
+ return value if isinstance(value, str) else None
340
+
341
+ @property
342
+ def location(self) -> str | None:
343
+ return self.header("location")
344
+
345
+
346
+ class TimedStreamCore:
347
+ """Times body consumption and reports read failures exactly once."""
348
+
349
+ def __init__(self, inner: Any, clock: Callable[[], float], on_done: Callable[[Outcome, float], None]) -> None:
350
+ self._inner = inner
351
+ self._clock = clock
352
+ self._on_done = on_done
353
+ self._started = clock()
354
+ self._finished = False
355
+
356
+ def _finish(self, outcome: Outcome) -> None:
357
+ if not self._finished:
358
+ self._finished = True
359
+ self._on_done(outcome, self._clock() - self._started)
360
+
361
+
362
+ class AsyncTimedStreamMixin(TimedStreamCore):
363
+ async def __aiter__(self) -> Any:
364
+ try:
365
+ async for chunk in self._inner:
366
+ yield chunk
367
+ except Exception as exc:
368
+ self._finish(Outcome(kind=FailureKind.BODY_ERROR, exception=exc))
369
+ raise
370
+ self._finish(Outcome(kind=None))
371
+
372
+ async def aclose(self) -> None:
373
+ try:
374
+ await self._inner.aclose()
375
+ finally:
376
+ self._finish(Outcome(kind=None))
377
+
378
+
379
+ class SyncTimedStreamMixin(TimedStreamCore):
380
+ def __iter__(self) -> Any:
381
+ try:
382
+ yield from self._inner
383
+ except Exception as exc:
384
+ self._finish(Outcome(kind=FailureKind.BODY_ERROR, exception=exc))
385
+ raise
386
+ self._finish(Outcome(kind=None))
387
+
388
+ def close(self) -> None:
389
+ try:
390
+ self._inner.close()
391
+ finally:
392
+ self._finish(Outcome(kind=None))
393
+
394
+
395
+ class AsyncFamilyNormalizer:
396
+ """Async normalizer template; subclasses bind SDK-specific stream classes."""
397
+
398
+ def __init__(
399
+ self,
400
+ default_timeout: dict[str, float | None],
401
+ clock: Callable[[], float],
402
+ *,
403
+ sdk: Any,
404
+ request_view: Callable[[Any], Any],
405
+ timed_stream: Callable[..., Any],
406
+ ) -> None:
407
+ self._default_timeout = default_timeout
408
+ self._clock = clock
409
+ self._sdk = sdk
410
+ self._request_view = request_view
411
+ self._timed_stream = timed_stream
412
+
413
+ def wrap_request(self, native: Any) -> Any:
414
+ return self._request_view(native)
415
+
416
+ def wrap_response(self, native: Any) -> FamilyResponseView:
417
+ return FamilyResponseView(native)
418
+
419
+ def classify_error(self, exc: BaseException) -> FailureKind:
420
+ return classify_family_error(self._sdk, exc)
421
+
422
+ def classify_response(self, response: Any) -> Outcome:
423
+ from ..core.engine.base import default_response_outcome # noqa: PLC0415 - avoid import cycle at module load
424
+
425
+ return default_response_outcome(response)
426
+
427
+ async def freeze(self, request: Any) -> bool:
428
+ try:
429
+ await request.native.aread()
430
+ except Exception:
431
+ return False
432
+ return True
433
+
434
+ async def rewind(self, request: Any) -> None:
435
+ return None
436
+
437
+ async def discard(self, response: Any) -> None:
438
+ try:
439
+ await response.native.aclose()
440
+ except Exception:
441
+ return None
442
+
443
+ def wrap_stream(self, response: Any, on_done: Callable[[Outcome, float], None]) -> None:
444
+ native = response.native
445
+ stream = native.stream
446
+ if isinstance(stream, self._sdk.AsyncByteStream):
447
+ native.stream = self._timed_stream(stream, self._clock, on_done)
448
+
449
+ def conn_metrics(self, response: Any) -> ConnMetrics | None:
450
+ return None
451
+
452
+
453
+ class SyncFamilyNormalizer:
454
+ """Sync twin; blocking body operations."""
455
+
456
+ def __init__(
457
+ self,
458
+ default_timeout: dict[str, float | None],
459
+ clock: Callable[[], float],
460
+ *,
461
+ sdk: Any,
462
+ request_view: Callable[[Any], Any],
463
+ timed_stream: Callable[..., Any],
464
+ ) -> None:
465
+ self._default_timeout = default_timeout
466
+ self._clock = clock
467
+ self._sdk = sdk
468
+ self._request_view = request_view
469
+ self._timed_stream = timed_stream
470
+
471
+ def wrap_request(self, native: Any) -> Any:
472
+ return self._request_view(native)
473
+
474
+ def wrap_response(self, native: Any) -> FamilyResponseView:
475
+ return FamilyResponseView(native)
476
+
477
+ def classify_error(self, exc: BaseException) -> FailureKind:
478
+ return classify_family_error(self._sdk, exc)
479
+
480
+ def classify_response(self, response: Any) -> Outcome:
481
+ from ..core.engine.base import default_response_outcome # noqa: PLC0415 - avoid import cycle at module load
482
+
483
+ return default_response_outcome(response)
484
+
485
+ def freeze(self, request: Any) -> bool:
486
+ try:
487
+ request.native.read()
488
+ except Exception:
489
+ return False
490
+ return True
491
+
492
+ def rewind(self, request: Any) -> None:
493
+ return None
494
+
495
+ def discard(self, response: Any) -> None:
496
+ try:
497
+ response.native.close()
498
+ except Exception:
499
+ return None
500
+
501
+ def wrap_stream(self, response: Any, on_done: Callable[[Outcome, float], None]) -> None:
502
+ native = response.native
503
+ stream = native.stream
504
+ if isinstance(stream, self._sdk.SyncByteStream):
505
+ native.stream = self._timed_stream(stream, self._clock, on_done)
506
+
507
+ def conn_metrics(self, response: Any) -> ConnMetrics | None:
508
+ return None
509
+
510
+
511
+ class AsyncEngineTransportMixin:
512
+ """Bound by adapters as ``class T(AsyncEngineTransportMixin, sdk.AsyncBaseTransport)``."""
513
+
514
+ def __init__(self, inner: Any, engine: AsyncAttemptEngine) -> None:
515
+ self._inner = inner
516
+ self._engine = engine
517
+
518
+ async def _send(self, request: Any) -> Any:
519
+ return await self._inner.handle_async_request(request.native)
520
+
521
+ async def handle_async_request(self, request: Any) -> Any:
522
+ return await self._engine.run(request, self._send)
523
+
524
+ async def aclose(self) -> None:
525
+ await self._inner.aclose()
526
+
527
+
528
+ class SyncEngineTransportMixin:
529
+ def __init__(self, inner: Any, engine: SyncAttemptEngine) -> None:
530
+ self._inner = inner
531
+ self._engine = engine
532
+
533
+ def _send(self, request: Any) -> Any:
534
+ return self._inner.handle_request(request.native)
535
+
536
+ def handle_request(self, request: Any) -> Any:
537
+ return self._engine.run(request, self._send)
538
+
539
+ def close(self) -> None:
540
+ self._inner.close()
541
+
542
+
543
+ class AsyncProxyRouterMixin:
544
+ """Routes every request - including owned-redirect hops - to the right proxy.
545
+
546
+ Client-level mounts are resolved by the client ONCE per logical call,
547
+ before the engine's redirect loop; this router sits INSIDE the engine seam
548
+ instead, so a hop that crosses schemes or lands on a NO_PROXY host
549
+ re-routes correctly.
550
+ """
551
+
552
+ def __init__(self, direct: Any, by_scheme: dict[str, Any], no_proxy_hosts: tuple[str, ...]) -> None:
553
+ self._direct = direct
554
+ self._by_scheme = by_scheme
555
+ self._no_proxy_hosts = no_proxy_hosts
556
+
557
+ def _pick(self, url: Any) -> Any:
558
+ if host_bypasses_proxy(url.host, self._no_proxy_hosts):
559
+ return self._direct
560
+ return self._by_scheme.get(url.scheme, self._direct)
561
+
562
+ async def handle_async_request(self, request: Any) -> Any:
563
+ return await self._pick(request.url).handle_async_request(request)
564
+
565
+ async def aclose(self) -> None:
566
+ await self._direct.aclose()
567
+ for transport in self._by_scheme.values():
568
+ await transport.aclose()
569
+
570
+
571
+ class SyncProxyRouterMixin:
572
+ def __init__(self, direct: Any, by_scheme: dict[str, Any], no_proxy_hosts: tuple[str, ...]) -> None:
573
+ self._direct = direct
574
+ self._by_scheme = by_scheme
575
+ self._no_proxy_hosts = no_proxy_hosts
576
+
577
+ def _pick(self, url: Any) -> Any:
578
+ if host_bypasses_proxy(url.host, self._no_proxy_hosts):
579
+ return self._direct
580
+ return self._by_scheme.get(url.scheme, self._direct)
581
+
582
+ def handle_request(self, request: Any) -> Any:
583
+ return self._pick(request.url).handle_request(request)
584
+
585
+ def close(self) -> None:
586
+ self._direct.close()
587
+ for transport in self._by_scheme.values():
588
+ transport.close()
589
+
590
+
591
+ class FamilyAdapter:
592
+ """The whole builder, shared; subclasses bind SDK and SDK-based classes."""
593
+
594
+ # Bound by each adapter:
595
+ name: str
596
+ capabilities: AdapterCapabilities
597
+ sdk: Any
598
+ async_normalizer: Callable[[dict[str, float | None], Callable[[], float]], Any]
599
+ sync_normalizer: Callable[[dict[str, float | None], Callable[[], float]], Any]
600
+ async_engine_transport: Callable[[Any, AsyncAttemptEngine], Any]
601
+ sync_engine_transport: Callable[[Any, SyncAttemptEngine], Any]
602
+ async_proxy_router: Callable[[Any, dict[str, Any], tuple[str, ...]], Any]
603
+ sync_proxy_router: Callable[[Any, dict[str, Any], tuple[str, ...]], Any]
604
+ translate: Callable[[CallError], BaseException]
605
+
606
+ native_slots = frozenset({"client", "transport"})
607
+ reserved_keys: Mapping[str, Mapping[str, str]] = {
608
+ "client": RESERVED_CLIENT_KEYS,
609
+ "transport": RESERVED_TRANSPORT_KEYS,
610
+ }
611
+ allowed_keys: Mapping[str, frozenset[str] | None] = {"client": None, "transport": None}
612
+
613
+ def _validated_native(self, config: ClientConfig, *, sync: bool) -> dict[str, dict[str, Any]]:
614
+ client_target = self.sdk.Client if sync else self.sdk.AsyncClient
615
+ transport_target = self.sdk.HTTPTransport if sync else self.sdk.AsyncHTTPTransport
616
+ return validate_native(
617
+ config.native,
618
+ slots=self.native_slots,
619
+ reserved=self.reserved_keys,
620
+ allowed=self.allowed_keys,
621
+ signature_targets={"client": client_target, "transport": transport_target},
622
+ config_conflicts={},
623
+ )
624
+
625
+ def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan:
626
+ applied = {
627
+ Capability.TIMEOUT_CONNECT,
628
+ Capability.TIMEOUT_READ,
629
+ Capability.TIMEOUT_WRITE,
630
+ Capability.TIMEOUT_POOL,
631
+ Capability.POOL_LIMIT_TOTAL,
632
+ Capability.KEEPALIVE,
633
+ Capability.REDIRECTS_OWNABLE,
634
+ }
635
+ if resolve(config.pool.http2, False):
636
+ applied.add(Capability.HTTP2)
637
+ if config.proxy is not None:
638
+ applied.add(Capability.PROXY)
639
+ emulated = {Capability.TIMEOUT_TOTAL}
640
+ dropped: dict[Capability, str] = {}
641
+ if sync:
642
+ # The sync engine cannot cancel a blocked socket call: no hard
643
+ # deadline, no per-attempt ceiling. Saying so in the report is the
644
+ # whole point of the report.
645
+ if is_set(config.timeout.attempt) and resolve(config.timeout.attempt, None) is not None:
646
+ dropped[Capability.TIMEOUT_ATTEMPT] = (
647
+ "sync engine cannot cancel a blocked attempt; only phase timeouts and the soft total apply"
648
+ )
649
+ else:
650
+ emulated |= {Capability.TIMEOUT_ATTEMPT, Capability.DEADLINE_HARD}
651
+ if resolve(config.pool.max_connections_per_host, None) is not None:
652
+ emulated.add(Capability.POOL_LIMIT_PER_HOST)
653
+ plan = compile_plan(
654
+ config,
655
+ self.capabilities,
656
+ native_timeout_defaults=NATIVE_TIMEOUT_DEFAULTS,
657
+ applied_natively=frozenset(applied),
658
+ emulated=frozenset(emulated),
659
+ dropped=dropped,
660
+ )
661
+ plan.report.enforce(config.on_unsupported)
662
+ return plan
663
+
664
+ def _limits(self, config: ClientConfig) -> Any:
665
+ return self.sdk.Limits(
666
+ max_connections=resolve(config.pool.max_connections, 100),
667
+ max_keepalive_connections=resolve(config.pool.max_keepalive, 20),
668
+ keepalive_expiry=resolve(config.pool.keepalive_expiry, 5.0),
669
+ )
670
+
671
+ def _client_timeout(self, base: ResolvedTimeouts) -> Any:
672
+ return self.sdk.Timeout(connect=base.connect, read=base.read, write=base.write, pool=base.pool_acquire)
673
+
674
+ def _transport_kwargs(self, config: ClientConfig, native_transport: dict[str, Any]) -> dict[str, Any]:
675
+ kwargs: dict[str, Any] = {
676
+ "http2": resolve(config.pool.http2, False),
677
+ "limits": self._limits(config),
678
+ }
679
+ kwargs.update(ssl_arguments(config))
680
+ kwargs.update(native_transport)
681
+ return kwargs
682
+
683
+ def _telemetry(self, config: ClientConfig, deps: AdapterDeps) -> ClientTelemetry:
684
+ return ClientTelemetry(
685
+ service=config.service_name,
686
+ adapter=self.name,
687
+ seam=self.capabilities.seam,
688
+ config=config.observability,
689
+ metrics=deps.metrics,
690
+ tracer=deps.tracer,
691
+ )
692
+
693
+ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
694
+ from urllib.request import getproxies # noqa: PLC0415 - stdlib, deferred for import cost
695
+
696
+ sdk = self.sdk
697
+ native = self._validated_native(config, sync=False)
698
+ telemetry = self._telemetry(config, deps)
699
+ runtime = deps.runtime or ClientRuntime.for_config(
700
+ config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
701
+ )
702
+ plan = self._compile(config, sync=False)
703
+ base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
704
+ engine = AsyncAttemptEngine(
705
+ plan=plan,
706
+ runtime=runtime,
707
+ telemetry=telemetry,
708
+ normalizer=type(self).async_normalizer(timeout_dict(base), runtime.clock),
709
+ deps=deps,
710
+ translate=type(self).translate,
711
+ )
712
+ transport_kwargs = self._transport_kwargs(config, native.get("transport", {}))
713
+
714
+ def make_raw(proxy: str | None) -> Any:
715
+ return sdk.AsyncHTTPTransport(proxy=proxy, **transport_kwargs)
716
+
717
+ explicit_proxy = config.proxy.url if config.proxy is not None else None
718
+ inner: Any = make_raw(explicit_proxy)
719
+ if config.proxy is not None and config.proxy.from_env:
720
+ proxies = getproxies()
721
+ by_scheme: dict[str, Any] = {
722
+ scheme: make_raw(proxies[scheme]) for scheme in ("http", "https") if scheme in proxies
723
+ }
724
+ if by_scheme:
725
+ # The router sits INSIDE the engine seam, so every owned-redirect
726
+ # hop re-routes through the right proxy (client-level mounts are
727
+ # resolved only once per logical call).
728
+ inner = type(self).async_proxy_router(make_raw(None), by_scheme, tuple(no_proxy_hosts(proxies)))
729
+ transport = type(self).async_engine_transport(inner, engine)
730
+ client = sdk.AsyncClient(
731
+ base_url=config.base_url or "",
732
+ transport=transport,
733
+ follow_redirects=config.redirects is RedirectMode.NATIVE,
734
+ max_redirects=config.max_redirects,
735
+ timeout=self._client_timeout(base),
736
+ **native.get("client", {}),
737
+ )
738
+ handle: ClientHandle[Any] = ClientHandle(
739
+ client=client,
740
+ adapter=self.name,
741
+ capabilities=self.capabilities,
742
+ report=plan.report,
743
+ runtime=runtime,
744
+ plan=plan,
745
+ aclose=client.aclose,
746
+ )
747
+ register_handle(client, handle)
748
+ return handle
749
+
750
+ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
751
+ from urllib.request import getproxies # noqa: PLC0415 - stdlib, deferred for import cost
752
+
753
+ sdk = self.sdk
754
+ native = self._validated_native(config, sync=True)
755
+ telemetry = self._telemetry(config, deps)
756
+ runtime = deps.runtime or ClientRuntime.for_config(
757
+ config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
758
+ )
759
+ plan = self._compile(config, sync=True)
760
+ base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
761
+ engine = SyncAttemptEngine(
762
+ plan=plan,
763
+ runtime=runtime,
764
+ telemetry=telemetry,
765
+ normalizer=type(self).sync_normalizer(timeout_dict(base), runtime.clock),
766
+ deps=deps,
767
+ translate=type(self).translate,
768
+ )
769
+ transport_kwargs = self._transport_kwargs(config, native.get("transport", {}))
770
+
771
+ def make_raw(proxy: str | None) -> Any:
772
+ return sdk.HTTPTransport(proxy=proxy, **transport_kwargs)
773
+
774
+ explicit_proxy = config.proxy.url if config.proxy is not None else None
775
+ inner: Any = make_raw(explicit_proxy)
776
+ if config.proxy is not None and config.proxy.from_env:
777
+ proxies = getproxies()
778
+ by_scheme: dict[str, Any] = {
779
+ scheme: make_raw(proxies[scheme]) for scheme in ("http", "https") if scheme in proxies
780
+ }
781
+ if by_scheme:
782
+ inner = type(self).sync_proxy_router(make_raw(None), by_scheme, tuple(no_proxy_hosts(proxies)))
783
+ transport = type(self).sync_engine_transport(inner, engine)
784
+ client = sdk.Client(
785
+ base_url=config.base_url or "",
786
+ transport=transport,
787
+ follow_redirects=config.redirects is RedirectMode.NATIVE,
788
+ max_redirects=config.max_redirects,
789
+ timeout=self._client_timeout(base),
790
+ **native.get("client", {}),
791
+ )
792
+ handle: ClientHandle[Any] = ClientHandle(
793
+ client=client,
794
+ adapter=self.name,
795
+ capabilities=self.capabilities,
796
+ report=plan.report,
797
+ runtime=runtime,
798
+ plan=plan,
799
+ close=client.close,
800
+ )
801
+ register_handle(client, handle)
802
+ return handle
803
+
804
+
805
+ __all__ = [
806
+ "BODY_HEADERS",
807
+ "IDEMPOTENT_EXTENSION",
808
+ "NATIVE_TIMEOUT_DEFAULTS",
809
+ "RESERVED_CLIENT_KEYS",
810
+ "RESERVED_TRANSPORT_KEYS",
811
+ "ROUTE_EXTENSION",
812
+ "AsyncEngineTransportMixin",
813
+ "AsyncFamilyNormalizer",
814
+ "AsyncProxyRouterMixin",
815
+ "AsyncTimedStreamMixin",
816
+ "FamilyAdapter",
817
+ "FamilyRequestView",
818
+ "FamilyResponseView",
819
+ "SyncEngineTransportMixin",
820
+ "SyncFamilyNormalizer",
821
+ "SyncProxyRouterMixin",
822
+ "SyncTimedStreamMixin",
823
+ "TimedStreamCore",
824
+ "capabilities_for",
825
+ "classify_family_error",
826
+ "host_bypasses_proxy",
827
+ "make_error_translator",
828
+ "no_proxy_hosts",
829
+ "ssl_arguments",
830
+ "timeout_dict",
831
+ ]