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.
- clientwright/__init__.py +179 -0
- clientwright/__version__.py +1 -0
- clientwright/adapters/__init__.py +3 -0
- clientwright/adapters/_httpx_shared.py +831 -0
- clientwright/adapters/_lazy.py +30 -0
- clientwright/adapters/aiohttp/__init__.py +45 -0
- clientwright/adapters/aiohttp/_imports.py +30 -0
- clientwright/adapters/aiohttp/adapter.py +236 -0
- clientwright/adapters/aiohttp/capabilities.py +81 -0
- clientwright/adapters/aiohttp/classify.py +59 -0
- clientwright/adapters/aiohttp/errors.py +57 -0
- clientwright/adapters/aiohttp/middleware.py +103 -0
- clientwright/adapters/aiohttp/normalize.py +64 -0
- clientwright/adapters/aiohttp/options.py +16 -0
- clientwright/adapters/aiohttp/trace.py +109 -0
- clientwright/adapters/aiohttp/views.py +108 -0
- clientwright/adapters/httpx/__init__.py +45 -0
- clientwright/adapters/httpx/_imports.py +17 -0
- clientwright/adapters/httpx/adapter.py +39 -0
- clientwright/adapters/httpx/capabilities.py +9 -0
- clientwright/adapters/httpx/classify.py +14 -0
- clientwright/adapters/httpx/errors.py +35 -0
- clientwright/adapters/httpx/normalize.py +27 -0
- clientwright/adapters/httpx/normalize_sync.py +27 -0
- clientwright/adapters/httpx/transport.py +40 -0
- clientwright/adapters/httpx/views.py +46 -0
- clientwright/adapters/httpx2/__init__.py +46 -0
- clientwright/adapters/httpx2/_imports.py +18 -0
- clientwright/adapters/httpx2/adapter.py +37 -0
- clientwright/adapters/httpx2/capabilities.py +9 -0
- clientwright/adapters/httpx2/classify.py +14 -0
- clientwright/adapters/httpx2/errors.py +36 -0
- clientwright/adapters/httpx2/normalize.py +27 -0
- clientwright/adapters/httpx2/normalize_sync.py +27 -0
- clientwright/adapters/httpx2/transport.py +35 -0
- clientwright/adapters/httpx2/views.py +45 -0
- clientwright/adapters/observability/__init__.py +26 -0
- clientwright/adapters/observability/_metrics/__init__.py +1 -0
- clientwright/adapters/observability/_metrics/prometheus.py +200 -0
- clientwright/adapters/observability/_tracing/__init__.py +3 -0
- clientwright/adapters/observability/_tracing/otel.py +61 -0
- clientwright/adapters/requests/__init__.py +45 -0
- clientwright/adapters/requests/_imports.py +21 -0
- clientwright/adapters/requests/adapter.py +206 -0
- clientwright/adapters/requests/capabilities.py +80 -0
- clientwright/adapters/requests/classify.py +62 -0
- clientwright/adapters/requests/errors.py +40 -0
- clientwright/adapters/requests/normalize.py +63 -0
- clientwright/adapters/requests/views.py +121 -0
- clientwright/adapters/urllib3/__init__.py +48 -0
- clientwright/adapters/urllib3/_imports.py +18 -0
- clientwright/adapters/urllib3/adapter.py +260 -0
- clientwright/adapters/urllib3/capabilities.py +86 -0
- clientwright/adapters/urllib3/classify.py +46 -0
- clientwright/adapters/urllib3/errors.py +55 -0
- clientwright/adapters/urllib3/normalize.py +51 -0
- clientwright/adapters/urllib3/views.py +119 -0
- clientwright/contrib/__init__.py +3 -0
- clientwright/contrib/deadline.py +107 -0
- clientwright/contrib/dishka.py +80 -0
- clientwright/core/__init__.py +6 -0
- clientwright/core/balancer/__init__.py +1 -0
- clientwright/core/balancer/policy.py +23 -0
- clientwright/core/capabilities.py +156 -0
- clientwright/core/config.py +367 -0
- clientwright/core/contracts/__init__.py +33 -0
- clientwright/core/contracts/adapter.py +62 -0
- clientwright/core/contracts/context.py +31 -0
- clientwright/core/contracts/message.py +118 -0
- clientwright/core/contracts/observability.py +92 -0
- clientwright/core/contracts/settings.py +140 -0
- clientwright/core/engine/__init__.py +1 -0
- clientwright/core/engine/aio.py +246 -0
- clientwright/core/engine/base.py +65 -0
- clientwright/core/engine/redirects.py +64 -0
- clientwright/core/engine/suppress.py +30 -0
- clientwright/core/engine/sync.py +241 -0
- clientwright/core/errors.py +113 -0
- clientwright/core/model.py +138 -0
- clientwright/core/native.py +84 -0
- clientwright/core/options.py +46 -0
- clientwright/core/plan.py +190 -0
- clientwright/core/policy/__init__.py +1 -0
- clientwright/core/policy/budget.py +76 -0
- clientwright/core/policy/circuit.py +169 -0
- clientwright/core/policy/concurrency.py +98 -0
- clientwright/core/policy/retry.py +80 -0
- clientwright/core/policy/timeout.py +64 -0
- clientwright/core/registry.py +75 -0
- clientwright/core/telemetry/__init__.py +6 -0
- clientwright/core/telemetry/emitter.py +163 -0
- clientwright/core/telemetry/names.py +61 -0
- clientwright/core/telemetry/null.py +89 -0
- clientwright/core/telemetry/redaction.py +24 -0
- clientwright/core/testing/__init__.py +7 -0
- clientwright/core/testing/doubles.py +107 -0
- clientwright/core/testing/origin.py +201 -0
- clientwright/py.typed +0 -0
- clientwright-0.1.0.dist-info/METADATA +210 -0
- clientwright-0.1.0.dist-info/RECORD +102 -0
- clientwright-0.1.0.dist-info/WHEEL +4 -0
- 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,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"]
|