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,64 @@
1
+ """Async normalizer: the whole aiohttp-specific contract for the async engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from ...core.contracts.message import RequestView, ResponseView
10
+ from ...core.engine.base import default_response_outcome
11
+ from ...core.model import ConnMetrics, FailureKind, Outcome
12
+ from ._imports import aiohttp
13
+ from .classify import classify_error
14
+ from .trace import current_conn_metrics
15
+ from .views import AiohttpRequestView, AiohttpResponseView
16
+
17
+
18
+ class AsyncAiohttpNormalizer:
19
+ def wrap_request(self, native: Any) -> AiohttpRequestView:
20
+ return AiohttpRequestView(native)
21
+
22
+ def wrap_response(self, native: Any) -> AiohttpResponseView:
23
+ return AiohttpResponseView(native)
24
+
25
+ def classify_error(self, exc: BaseException) -> FailureKind:
26
+ return classify_error(exc)
27
+
28
+ def classify_response(self, response: ResponseView) -> Outcome:
29
+ return default_response_outcome(response)
30
+
31
+ async def freeze(self, request: RequestView) -> bool:
32
+ """Bytes-backed payloads replay for free; streams and files do not."""
33
+ body = request.native.body
34
+ if body is None:
35
+ return True
36
+ if isinstance(body, (bytes, bytearray, memoryview)):
37
+ return True
38
+ if isinstance(body, aiohttp.payload.BytesPayload):
39
+ return not bool(getattr(body, "consumed", False))
40
+ return False
41
+
42
+ async def rewind(self, request: RequestView) -> None:
43
+ # BytesPayload re-emits its full content on every send; nothing to do.
44
+ return None
45
+
46
+ async def discard(self, response: ResponseView) -> None:
47
+ """MANDATORY before a repeat - otherwise the connection never returns to the pool."""
48
+ try:
49
+ result = response.native.release()
50
+ if inspect.isawaitable(result):
51
+ await result
52
+ except Exception:
53
+ return None
54
+
55
+ def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
56
+ # The middleware returns at headers; the body streams outside the seam.
57
+ # Declared in capabilities (note "body_duration"), not silently skipped.
58
+ return None
59
+
60
+ def conn_metrics(self, response: ResponseView) -> ConnMetrics | None:
61
+ return current_conn_metrics()
62
+
63
+
64
+ __all__ = ["AsyncAiohttpNormalizer"]
@@ -0,0 +1,16 @@
1
+ """Per-call options channel for aiohttp.
2
+
3
+ aiohttp has no analogue of ``httpx.Request.extensions``: nothing the caller
4
+ passes to ``session.get(...)`` reaches a middleware. The route template and
5
+ idempotency override therefore travel through the shared core ContextVar
6
+ channel; this module re-exports it under the adapter's public path.
7
+
8
+ with call_options(route="/users/{id}", idempotent=True):
9
+ await session.post(f"/users/{user_id}")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ...core.options import CallOptions, call_options, current_call_options
15
+
16
+ __all__ = ["CallOptions", "call_options", "current_call_options"]
@@ -0,0 +1,109 @@
1
+ """TraceConfig: the observability that survives a bypassed middleware.
2
+
3
+ Per-request ``middlewares=()`` REPLACES session middlewares - the official
4
+ cookbook even teaches the pattern - but ``trace_configs`` cannot be overridden
5
+ per request. Two things therefore live here and only here:
6
+
7
+ - the ``uninstrumented_calls_total`` sentinel: a request that starts while the
8
+ engine middleware is NOT on the stack has bypassed every policy and metric;
9
+ - connection-level timings (DNS, connect, pool wait, connection reuse) that no
10
+ middleware can see.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass
18
+ from types import SimpleNamespace
19
+ from typing import Any
20
+
21
+ from ...core.model import ConnMetrics
22
+ from ...core.telemetry.emitter import ClientTelemetry
23
+ from ._imports import aiohttp
24
+
25
+ # True while the engine middleware is running in this task; TraceConfig reads
26
+ # it to detect requests that bypassed the seam.
27
+ ENGINE_ACTIVE: ContextVar[bool] = ContextVar("clientwright_aiohttp_engine_active", default=False)
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class _ConnTrace:
32
+ dns_started: float | None = None
33
+ dns: float | None = None
34
+ connect_started: float | None = None
35
+ connect: float | None = None
36
+ queued_started: float | None = None
37
+ pool_wait: float | None = None
38
+ reused: bool | None = None
39
+
40
+
41
+ # Trace callbacks run in the requesting task, so a plain ContextVar correlates
42
+ # them with the response the normalizer later reads conn metrics for.
43
+ _CONN_TRACE: ContextVar[_ConnTrace | None] = ContextVar("clientwright_aiohttp_conn_trace", default=None)
44
+
45
+
46
+ def current_conn_metrics() -> ConnMetrics | None:
47
+ trace = _CONN_TRACE.get()
48
+ if trace is None:
49
+ return None
50
+ return ConnMetrics(dns=trace.dns, connect=trace.connect, pool_wait=trace.pool_wait, reused=trace.reused)
51
+
52
+
53
+ def build_trace_config(telemetry: ClientTelemetry, clock: Callable[[], float]) -> aiohttp.TraceConfig:
54
+ trace_config = aiohttp.TraceConfig()
55
+
56
+ async def on_request_start(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
57
+ if not ENGINE_ACTIVE.get():
58
+ telemetry.uninstrumented_call()
59
+ _CONN_TRACE.set(_ConnTrace())
60
+
61
+ async def on_dns_start(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
62
+ trace = _CONN_TRACE.get()
63
+ if trace is not None:
64
+ trace.dns_started = clock()
65
+
66
+ async def on_dns_end(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
67
+ trace = _CONN_TRACE.get()
68
+ if trace is not None and trace.dns_started is not None:
69
+ trace.dns = clock() - trace.dns_started
70
+
71
+ async def on_connect_start(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
72
+ trace = _CONN_TRACE.get()
73
+ if trace is not None:
74
+ trace.connect_started = clock()
75
+
76
+ async def on_connect_end(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
77
+ trace = _CONN_TRACE.get()
78
+ if trace is not None:
79
+ trace.reused = False
80
+ if trace.connect_started is not None:
81
+ trace.connect = clock() - trace.connect_started
82
+
83
+ async def on_reuseconn(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
84
+ trace = _CONN_TRACE.get()
85
+ if trace is not None:
86
+ trace.reused = True
87
+
88
+ async def on_queued_start(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
89
+ trace = _CONN_TRACE.get()
90
+ if trace is not None:
91
+ trace.queued_started = clock()
92
+
93
+ async def on_queued_end(session: aiohttp.ClientSession, ctx: SimpleNamespace, params: Any) -> None:
94
+ trace = _CONN_TRACE.get()
95
+ if trace is not None and trace.queued_started is not None:
96
+ trace.pool_wait = clock() - trace.queued_started
97
+
98
+ trace_config.on_request_start.append(on_request_start)
99
+ trace_config.on_dns_resolvehost_start.append(on_dns_start)
100
+ trace_config.on_dns_resolvehost_end.append(on_dns_end)
101
+ trace_config.on_connection_create_start.append(on_connect_start)
102
+ trace_config.on_connection_create_end.append(on_connect_end)
103
+ trace_config.on_connection_reuseconn.append(on_reuseconn)
104
+ trace_config.on_connection_queued_start.append(on_queued_start)
105
+ trace_config.on_connection_queued_end.append(on_queued_end)
106
+ return trace_config
107
+
108
+
109
+ __all__ = ["ENGINE_ACTIVE", "build_trace_config", "current_conn_metrics"]
@@ -0,0 +1,108 @@
1
+ """RequestView/ResponseView implementations over aiohttp objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import MutableMapping
6
+ from typing import cast
7
+
8
+ from ...core.model import IDEMPOTENT_METHODS, RequestInfo, ResolvedTimeouts, origin_of
9
+ from ._imports import URL, aiohttp
10
+ from .options import current_call_options
11
+
12
+ _BODY_HEADERS = ("Content-Length", "Content-Type", "Transfer-Encoding")
13
+
14
+
15
+ def _host_header(url: URL) -> str:
16
+ host = url.host or ""
17
+ if url.explicit_port is None or url.is_default_port():
18
+ return host
19
+ return f"{host}:{url.explicit_port}"
20
+
21
+
22
+ class AiohttpRequestView:
23
+ __slots__ = ("_options", "_request", "pending_empty_body")
24
+
25
+ def __init__(self, request: aiohttp.ClientRequest) -> None:
26
+ self._request = request
27
+ # Snapshot ONCE: the view lives for the whole logical call, while the
28
+ # caller's context may have moved on by the time a retry fires.
29
+ self._options = current_call_options()
30
+ # Bodies can only be replaced with an await (update_body); a sync
31
+ # retarget marks the drop and the middleware applies it before send.
32
+ self.pending_empty_body = False
33
+
34
+ @property
35
+ def native(self) -> aiohttp.ClientRequest:
36
+ return self._request
37
+
38
+ @property
39
+ def info(self) -> RequestInfo:
40
+ request = self._request
41
+ method = request.method
42
+ url = str(request.url)
43
+ options = self._options
44
+ route = options.route if options is not None else None
45
+ if options is not None and options.idempotent is not None:
46
+ idempotent = options.idempotent
47
+ else:
48
+ idempotent = method in IDEMPOTENT_METHODS
49
+ return RequestInfo(method=method, origin=origin_of(url), url=url, route=route, idempotent=idempotent)
50
+
51
+ @property
52
+ def headers(self) -> MutableMapping[str, str]:
53
+ return cast(MutableMapping[str, str], self._request.headers)
54
+
55
+ def caller_timeouts(self) -> ResolvedTimeouts | None:
56
+ # Per-request timeout= exists in aiohttp but is consumed by _request
57
+ # before the middleware runs; there is no channel to observe it here.
58
+ return None
59
+
60
+ def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
61
+ # Phase timeouts are fixed at session level (ClientRequest.timeout is
62
+ # read-only); the engine's own asyncio.timeout enforces the attempt
63
+ # ceiling, which is the knob that actually varies per attempt.
64
+ return None
65
+
66
+ def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
67
+ request = self._request
68
+ target = URL(url)
69
+ request.url = target
70
+ # aiohttp builds each ClientResponse from original_url; without this the
71
+ # final response (and its cookies) would be attributed to the
72
+ # pre-redirect URL.
73
+ request.original_url = target
74
+ # Host was computed at construction time; a retargeted request must
75
+ # not present the old host to the new server.
76
+ request.headers["Host"] = _host_header(target)
77
+ if method is not None:
78
+ request.method = method
79
+ if drop_body:
80
+ for name in _BODY_HEADERS:
81
+ request.headers.pop(name, None)
82
+ self.pending_empty_body = True
83
+
84
+
85
+ class AiohttpResponseView:
86
+ __slots__ = ("_response",)
87
+
88
+ def __init__(self, response: aiohttp.ClientResponse) -> None:
89
+ self._response = response
90
+
91
+ @property
92
+ def native(self) -> aiohttp.ClientResponse:
93
+ return self._response
94
+
95
+ @property
96
+ def status_code(self) -> int:
97
+ return self._response.status
98
+
99
+ def header(self, name: str) -> str | None:
100
+ value = self._response.headers.get(name)
101
+ return value if isinstance(value, str) else None
102
+
103
+ @property
104
+ def location(self) -> str | None:
105
+ return self.header("Location")
106
+
107
+
108
+ __all__ = ["AiohttpRequestView", "AiohttpResponseView"]
@@ -0,0 +1,45 @@
1
+ """httpx adapter (``[httpx]`` extra): real httpx clients, engine underneath.
2
+
3
+ Names resolve lazily (see ``clientwright.adapters._lazy``): importing this
4
+ package never imports httpx, so the capabilities matrix stays extras-free.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .._lazy import lazy_attribute
12
+
13
+ if TYPE_CHECKING:
14
+ from .adapter import HttpxAdapter as HttpxAdapter
15
+ from .capabilities import CAPABILITIES as CAPABILITIES
16
+ from .errors import HttpxCircuitOpenError as HttpxCircuitOpenError
17
+ from .errors import HttpxDeadlineExceededError as HttpxDeadlineExceededError
18
+ from .errors import HttpxTooManyRedirectsError as HttpxTooManyRedirectsError
19
+ from .views import IDEMPOTENT_EXTENSION as IDEMPOTENT_EXTENSION
20
+ from .views import ROUTE_EXTENSION as ROUTE_EXTENSION
21
+
22
+ _EXPORTS = {
23
+ "CAPABILITIES": "capabilities",
24
+ "HttpxAdapter": "adapter",
25
+ "HttpxCircuitOpenError": "errors",
26
+ "HttpxDeadlineExceededError": "errors",
27
+ "HttpxTooManyRedirectsError": "errors",
28
+ "IDEMPOTENT_EXTENSION": "views",
29
+ "ROUTE_EXTENSION": "views",
30
+ }
31
+
32
+
33
+ def __getattr__(name: str) -> Any:
34
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
35
+
36
+
37
+ __all__ = [
38
+ "CAPABILITIES",
39
+ "IDEMPOTENT_EXTENSION",
40
+ "ROUTE_EXTENSION",
41
+ "HttpxAdapter",
42
+ "HttpxCircuitOpenError",
43
+ "HttpxDeadlineExceededError",
44
+ "HttpxTooManyRedirectsError",
45
+ ]
@@ -0,0 +1,17 @@
1
+ """Lazy import guard for the optional httpx stack.
2
+
3
+ Importing this module fails with a friendly message when the ``httpx`` extra is
4
+ not installed, so ``import clientwright`` never pays the cost (or the failure).
5
+ Every httpx submodule imports its third-party symbols from here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _INSTALL_HINT = "httpx support requires clientwright[httpx]; install it."
11
+
12
+ try:
13
+ import httpx
14
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
15
+ raise ImportError(_INSTALL_HINT) from exc
16
+
17
+ __all__ = ["httpx"]
@@ -0,0 +1,39 @@
1
+ """The httpx adapter: the shared family builder bound to the httpx SDK.
2
+
3
+ Constructor traps the shared builder dodges (verified against httpx 0.28):
4
+ - ``Client(transport=...)`` silently ignores ``verify``/``http2``/``limits`` -
5
+ everything pool/TLS-related therefore goes into the TRANSPORT constructor;
6
+ - a custom transport disables env-proxy support - when asked, proxies are
7
+ parsed from the environment into engine-wrapped routing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .._httpx_shared import FamilyAdapter
13
+ from ._imports import httpx
14
+ from .capabilities import CAPABILITIES
15
+ from .errors import translate_call_error
16
+ from .normalize import AsyncHttpxNormalizer
17
+ from .normalize_sync import SyncHttpxNormalizer
18
+ from .transport import (
19
+ AsyncEngineTransport,
20
+ AsyncProxyRouterTransport,
21
+ SyncEngineTransport,
22
+ SyncProxyRouterTransport,
23
+ )
24
+
25
+
26
+ class HttpxAdapter(FamilyAdapter):
27
+ name = "httpx"
28
+ capabilities = CAPABILITIES
29
+ sdk = httpx
30
+ async_normalizer = AsyncHttpxNormalizer
31
+ sync_normalizer = SyncHttpxNormalizer
32
+ async_engine_transport = AsyncEngineTransport
33
+ sync_engine_transport = SyncEngineTransport
34
+ async_proxy_router = AsyncProxyRouterTransport
35
+ sync_proxy_router = SyncProxyRouterTransport
36
+ translate = staticmethod(translate_call_error)
37
+
38
+
39
+ __all__ = ["HttpxAdapter"]
@@ -0,0 +1,9 @@
1
+ """httpx capability declaration. Zero-dependency: never imports httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._httpx_shared import capabilities_for
6
+
7
+ CAPABILITIES = capabilities_for("httpx")
8
+
9
+ __all__ = ["CAPABILITIES"]
@@ -0,0 +1,14 @@
1
+ """httpx exception -> FailureKind classification (shared family logic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.model import FailureKind
6
+ from .._httpx_shared import classify_family_error
7
+ from ._imports import httpx
8
+
9
+
10
+ def classify_error(exc: BaseException) -> FailureKind:
11
+ return classify_family_error(httpx, exc)
12
+
13
+
14
+ __all__ = ["classify_error"]
@@ -0,0 +1,35 @@
1
+ """Kernel errors dual-inherited into the httpx family.
2
+
3
+ A user's ``except httpx.HTTPError`` (or ``except httpx.TimeoutException``)
4
+ keeps working when clientwright raises on its own authority.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...core.errors import CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
10
+ from .._httpx_shared import make_error_translator
11
+ from ._imports import httpx
12
+
13
+
14
+ class HttpxCircuitOpenError(CircuitOpenError, httpx.HTTPError):
15
+ """Circuit open, catchable as httpx.HTTPError."""
16
+
17
+
18
+ class HttpxDeadlineExceededError(DeadlineExceededError, httpx.TimeoutException):
19
+ """Total deadline exhausted, catchable as httpx.TimeoutException."""
20
+
21
+
22
+ class HttpxTooManyRedirectsError(TooManyRedirectsError, httpx.TooManyRedirects):
23
+ """Owned redirect limit exceeded, catchable as httpx.TooManyRedirects."""
24
+
25
+
26
+ translate_call_error = make_error_translator(
27
+ HttpxCircuitOpenError, HttpxDeadlineExceededError, HttpxTooManyRedirectsError
28
+ )
29
+
30
+ __all__ = [
31
+ "HttpxCircuitOpenError",
32
+ "HttpxDeadlineExceededError",
33
+ "HttpxTooManyRedirectsError",
34
+ "translate_call_error",
35
+ ]
@@ -0,0 +1,27 @@
1
+ """Async normalizer binding: shared family logic over the httpx SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from .._httpx_shared import AsyncFamilyNormalizer, AsyncTimedStreamMixin
8
+ from ._imports import httpx
9
+ from .views import HttpxRequestView
10
+
11
+
12
+ class _TimedAsyncStream(AsyncTimedStreamMixin, httpx.AsyncByteStream):
13
+ """Times body consumption and reports read failures exactly once."""
14
+
15
+
16
+ class AsyncHttpxNormalizer(AsyncFamilyNormalizer):
17
+ def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
18
+ super().__init__(
19
+ default_timeout,
20
+ clock,
21
+ sdk=httpx,
22
+ request_view=lambda native: HttpxRequestView(native, default_timeout),
23
+ timed_stream=_TimedAsyncStream,
24
+ )
25
+
26
+
27
+ __all__ = ["AsyncHttpxNormalizer"]
@@ -0,0 +1,27 @@
1
+ """Sync normalizer binding: same contract, blocking body operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from .._httpx_shared import SyncFamilyNormalizer, SyncTimedStreamMixin
8
+ from ._imports import httpx
9
+ from .views import HttpxRequestView
10
+
11
+
12
+ class _TimedSyncStream(SyncTimedStreamMixin, httpx.SyncByteStream):
13
+ """Times body consumption and reports read failures exactly once."""
14
+
15
+
16
+ class SyncHttpxNormalizer(SyncFamilyNormalizer):
17
+ def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
18
+ super().__init__(
19
+ default_timeout,
20
+ clock,
21
+ sdk=httpx,
22
+ request_view=lambda native: HttpxRequestView(native, default_timeout),
23
+ timed_stream=_TimedSyncStream,
24
+ )
25
+
26
+
27
+ __all__ = ["SyncHttpxNormalizer"]
@@ -0,0 +1,40 @@
1
+ """Engine-driven httpx transports: shared mixin logic bound to httpx bases.
2
+
3
+ The seam sits UNDER the public API: the returned client is a genuine
4
+ ``httpx.AsyncClient`` / ``httpx.Client`` and every request that flows through
5
+ it - including one sent via a raw ``client.send`` - passes the engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .._httpx_shared import (
11
+ AsyncEngineTransportMixin,
12
+ AsyncProxyRouterMixin,
13
+ SyncEngineTransportMixin,
14
+ SyncProxyRouterMixin,
15
+ )
16
+ from ._imports import httpx
17
+
18
+
19
+ class AsyncEngineTransport(AsyncEngineTransportMixin, httpx.AsyncBaseTransport):
20
+ pass
21
+
22
+
23
+ class SyncEngineTransport(SyncEngineTransportMixin, httpx.BaseTransport):
24
+ pass
25
+
26
+
27
+ class AsyncProxyRouterTransport(AsyncProxyRouterMixin, httpx.AsyncBaseTransport):
28
+ pass
29
+
30
+
31
+ class SyncProxyRouterTransport(SyncProxyRouterMixin, httpx.BaseTransport):
32
+ pass
33
+
34
+
35
+ __all__ = [
36
+ "AsyncEngineTransport",
37
+ "AsyncProxyRouterTransport",
38
+ "SyncEngineTransport",
39
+ "SyncProxyRouterTransport",
40
+ ]
@@ -0,0 +1,46 @@
1
+ """RequestView/ResponseView bindings over httpx objects.
2
+
3
+ The logic lives in ``adapters/_httpx_shared``; this module binds it to the
4
+ httpx SDK. The request class is shared by the sync and async clients, so one
5
+ pair of views serves both normalizers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator, Iterator
11
+
12
+ from .._httpx_shared import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION, FamilyRequestView, FamilyResponseView
13
+ from ._imports import httpx
14
+
15
+
16
+ class _ReplayStream(httpx.SyncByteStream, httpx.AsyncByteStream):
17
+ """A byte stream that can be iterated any number of times."""
18
+
19
+ def __init__(self, content: bytes) -> None:
20
+ self._content = content
21
+
22
+ def __iter__(self) -> Iterator[bytes]:
23
+ yield self._content
24
+
25
+ async def __aiter__(self) -> AsyncIterator[bytes]:
26
+ yield self._content
27
+
28
+
29
+ class HttpxRequestView(FamilyRequestView):
30
+ __slots__ = ()
31
+
32
+ def __init__(self, request: httpx.Request, default_timeout: dict[str, float | None]) -> None:
33
+ super().__init__(request, default_timeout, sdk=httpx, replay_stream=_ReplayStream)
34
+
35
+
36
+ class HttpxResponseView(FamilyResponseView):
37
+ __slots__ = ()
38
+
39
+
40
+ __all__ = [
41
+ "IDEMPOTENT_EXTENSION",
42
+ "ROUTE_EXTENSION",
43
+ "HttpxRequestView",
44
+ "HttpxResponseView",
45
+ "_ReplayStream",
46
+ ]
@@ -0,0 +1,46 @@
1
+ """httpx2 adapter (``[httpx2]`` extra): real httpx2 clients, engine underneath.
2
+
3
+ Names resolve lazily (see ``clientwright.adapters._lazy``): importing this
4
+ package never imports httpx2, so the capabilities matrix stays extras-free.
5
+ Public names are deliberately IDENTICAL to the httpx adapter's.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .._lazy import lazy_attribute
13
+
14
+ if TYPE_CHECKING:
15
+ from .adapter import HttpxAdapter as HttpxAdapter
16
+ from .capabilities import CAPABILITIES as CAPABILITIES
17
+ from .errors import HttpxCircuitOpenError as HttpxCircuitOpenError
18
+ from .errors import HttpxDeadlineExceededError as HttpxDeadlineExceededError
19
+ from .errors import HttpxTooManyRedirectsError as HttpxTooManyRedirectsError
20
+ from .views import IDEMPOTENT_EXTENSION as IDEMPOTENT_EXTENSION
21
+ from .views import ROUTE_EXTENSION as ROUTE_EXTENSION
22
+
23
+ _EXPORTS = {
24
+ "CAPABILITIES": "capabilities",
25
+ "HttpxAdapter": "adapter",
26
+ "HttpxCircuitOpenError": "errors",
27
+ "HttpxDeadlineExceededError": "errors",
28
+ "HttpxTooManyRedirectsError": "errors",
29
+ "IDEMPOTENT_EXTENSION": "views",
30
+ "ROUTE_EXTENSION": "views",
31
+ }
32
+
33
+
34
+ def __getattr__(name: str) -> Any:
35
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
36
+
37
+
38
+ __all__ = [
39
+ "CAPABILITIES",
40
+ "IDEMPOTENT_EXTENSION",
41
+ "ROUTE_EXTENSION",
42
+ "HttpxAdapter",
43
+ "HttpxCircuitOpenError",
44
+ "HttpxDeadlineExceededError",
45
+ "HttpxTooManyRedirectsError",
46
+ ]
@@ -0,0 +1,18 @@
1
+ """Lazy import guard for the optional httpx2 stack.
2
+
3
+ httpx2 is the httpx successor maintained by Pydantic Services Inc.: a separate
4
+ distribution and import namespace with the same API surface. Importing this
5
+ module fails with a friendly message when the ``httpx2`` extra is not
6
+ installed. Every httpx2 submodule imports its third-party symbols from here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ _INSTALL_HINT = "httpx2 support requires clientwright[httpx2]; install it."
12
+
13
+ try:
14
+ import httpx2
15
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
16
+ raise ImportError(_INSTALL_HINT) from exc
17
+
18
+ __all__ = ["httpx2"]