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,206 @@
1
+ """The requests adapter: builds real Session objects with the engine underneath.
2
+
3
+ The seam is a mounted ``HTTPAdapter`` subclass whose ``send()`` runs the
4
+ engine; the returned object is a genuine ``requests.Session``. Two constraints
5
+ this builder honors (verified against requests 2.32):
6
+
7
+ - requests has NO session-level timeout default - a bare ``session.get()``
8
+ hangs forever; the engine sends every attempt with its planned
9
+ ``(connect, read)`` tuple, closing that hole;
10
+ - native retries execute INSIDE ``conn.urlopen``, invisible to metrics and the
11
+ breaker - they are pinned to ``Retry(0, read=False)`` and the engine owns
12
+ the loop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from typing import Any
19
+
20
+ from ...core.capabilities import Capability
21
+ from ...core.config import ClientConfig, is_set, resolve
22
+ from ...core.contracts.adapter import AdapterDeps
23
+ from ...core.contracts.message import RequestView
24
+ from ...core.engine.suppress import is_suppressed, suppressed
25
+ from ...core.engine.sync import SyncAttemptEngine
26
+ from ...core.errors import UnsupportedCapabilityError
27
+ from ...core.model import ResolvedTimeouts
28
+ from ...core.native import validate_native
29
+ from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
30
+ from ...core.telemetry.emitter import ClientTelemetry
31
+ from ._imports import HTTPAdapter, requests, urllib3
32
+ from .capabilities import CAPABILITIES
33
+ from .errors import translate_call_error
34
+ from .normalize import SyncRequestsNormalizer
35
+ from .views import CALLER_TIMEOUT_ATTRIBUTE, RequestsRequestView
36
+
37
+ # requests has no timeout defaults at all: an UNSET phase stays unbounded and
38
+ # only the engine's total deadline clamps it.
39
+ _NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts()
40
+
41
+ _SESSION_ATTRS = frozenset({"headers", "cookies", "auth", "params", "hooks", "stream", "trust_env"})
42
+
43
+ _RESERVED_SESSION_KEYS: Mapping[str, str] = {
44
+ "verify": "use ClientConfig.tls",
45
+ "cert": "use ClientConfig.tls.cert",
46
+ "proxies": "use ClientConfig.proxy",
47
+ "max_redirects": "use ClientConfig.max_redirects",
48
+ "adapters": "the mounted adapters belong to clientwright",
49
+ }
50
+
51
+ _RESERVED_ADAPTER_KEYS: Mapping[str, str] = {
52
+ "max_retries": "retries belong to clientwright; use ClientConfig.retry",
53
+ "pool_connections": "use ClientConfig.pool",
54
+ "pool_maxsize": "use ClientConfig.pool.max_connections_per_host",
55
+ "pool_block": "use ClientConfig.pool.max_connections_per_host",
56
+ }
57
+
58
+
59
+ class EngineHTTPAdapter(HTTPAdapter):
60
+ """HTTPAdapter whose send() is one engine-driven logical call."""
61
+
62
+ def __init__(self, engine: SyncAttemptEngine, **kwargs: Any) -> None:
63
+ kwargs.setdefault("max_retries", urllib3.util.Retry(0, read=False))
64
+ super().__init__(**kwargs)
65
+ self._engine = engine
66
+
67
+ def send(
68
+ self,
69
+ request: requests.PreparedRequest,
70
+ stream: bool = False,
71
+ timeout: Any = None,
72
+ verify: bool | str = True,
73
+ cert: Any = None,
74
+ proxies: Any = None,
75
+ ) -> requests.Response:
76
+ if is_suppressed():
77
+ # An outer clientwright layer owns instrumentation for this call.
78
+ return super().send(request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies)
79
+ setattr(request, CALLER_TIMEOUT_ATTRIBUTE, timeout)
80
+
81
+ def send_view(view: RequestView) -> requests.Response:
82
+ planned = view.planned if isinstance(view, RequestsRequestView) else None
83
+ attempt_timeout = (planned.connect, planned.read) if planned is not None else timeout
84
+ with suppressed():
85
+ return super(EngineHTTPAdapter, self).send(
86
+ view.native, stream=stream, timeout=attempt_timeout, verify=verify, cert=cert, proxies=proxies
87
+ )
88
+
89
+ response = self._engine.run(request, send_view)
90
+ assert isinstance(response, requests.Response)
91
+ return response
92
+
93
+
94
+ class RequestsAdapter:
95
+ name = "requests"
96
+ capabilities = CAPABILITIES
97
+ native_slots = frozenset({"session", "adapter"})
98
+ reserved_keys: Mapping[str, Mapping[str, str]] = {
99
+ "session": _RESERVED_SESSION_KEYS,
100
+ "adapter": _RESERVED_ADAPTER_KEYS,
101
+ }
102
+ allowed_keys: Mapping[str, frozenset[str] | None] = {"session": _SESSION_ATTRS, "adapter": None}
103
+
104
+ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]:
105
+ return validate_native(
106
+ config.native,
107
+ slots=self.native_slots,
108
+ reserved=self.reserved_keys,
109
+ allowed=self.allowed_keys,
110
+ signature_targets={"adapter": HTTPAdapter},
111
+ config_conflicts={},
112
+ )
113
+
114
+ def _compile(self, config: ClientConfig) -> CallPlan:
115
+ applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
116
+ if resolve(config.pool.max_connections_per_host, None) is not None:
117
+ applied.add(Capability.POOL_LIMIT_PER_HOST)
118
+ if config.proxy is not None:
119
+ applied.add(Capability.PROXY)
120
+ emulated = {Capability.TIMEOUT_TOTAL}
121
+ dropped: dict[Capability, str] = {}
122
+ if is_set(config.timeout.attempt) and resolve(config.timeout.attempt, None) is not None:
123
+ dropped[Capability.TIMEOUT_ATTEMPT] = (
124
+ "sync engine cannot cancel a blocked attempt; only phase timeouts and the soft total apply"
125
+ )
126
+ if is_set(config.timeout.write) and resolve(config.timeout.write, None) is not None:
127
+ dropped[Capability.TIMEOUT_WRITE] = "requests has no write timeout"
128
+ if is_set(config.timeout.pool_acquire) and resolve(config.timeout.pool_acquire, None) is not None:
129
+ dropped[Capability.TIMEOUT_POOL] = "HTTPAdapter.send never forwards pool_timeout to urllib3"
130
+ if resolve(config.pool.http2, False):
131
+ dropped[Capability.HTTP2] = "requests speaks HTTP/1.1 only"
132
+ plan = compile_plan(
133
+ config,
134
+ CAPABILITIES,
135
+ native_timeout_defaults=_NATIVE_TIMEOUT_DEFAULTS,
136
+ applied_natively=frozenset(applied),
137
+ emulated=frozenset(emulated),
138
+ dropped=dropped,
139
+ )
140
+ plan.report.enforce(config.on_unsupported)
141
+ return plan
142
+
143
+ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
144
+ if config.base_url is not None:
145
+ raise UnsupportedCapabilityError("requests has no base_url; pass absolute URLs (or use the httpx adapter)")
146
+ native = self._validated_native(config)
147
+ telemetry = ClientTelemetry(
148
+ service=config.service_name,
149
+ adapter=self.name,
150
+ seam=CAPABILITIES.seam,
151
+ config=config.observability,
152
+ metrics=deps.metrics,
153
+ tracer=deps.tracer,
154
+ )
155
+ runtime = deps.runtime or ClientRuntime.for_config(
156
+ config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
157
+ )
158
+ plan = self._compile(config)
159
+ engine = SyncAttemptEngine(
160
+ plan=plan,
161
+ runtime=runtime,
162
+ telemetry=telemetry,
163
+ normalizer=SyncRequestsNormalizer(),
164
+ deps=deps,
165
+ translate=translate_call_error,
166
+ )
167
+ per_host = resolve(config.pool.max_connections_per_host, None)
168
+ adapter_kwargs: dict[str, Any] = {
169
+ "pool_maxsize": per_host if per_host is not None else resolve(config.pool.max_connections, 10) or 10,
170
+ "pool_block": per_host is not None,
171
+ }
172
+ adapter_kwargs.update(native.get("adapter", {}))
173
+ engine_adapter = EngineHTTPAdapter(engine, **adapter_kwargs)
174
+ session = requests.Session()
175
+ session.mount("http://", engine_adapter)
176
+ session.mount("https://", engine_adapter)
177
+ session.verify = config.tls.ca_bundle if config.tls.ca_bundle is not None else config.tls.verify
178
+ cert = config.tls.cert
179
+ if isinstance(cert, tuple) and len(cert) == 3:
180
+ raise UnsupportedCapabilityError("requests does not support a client cert with a key password")
181
+ session.cert = cert
182
+ session.max_redirects = config.max_redirects
183
+ if config.proxy is not None and config.proxy.url is not None:
184
+ session.proxies.update({"http": config.proxy.url, "https": config.proxy.url})
185
+ # config.proxy.from_env needs nothing: requests reads env proxies natively.
186
+ for attr, value in native.get("session", {}).items():
187
+ setattr(session, attr, value)
188
+ handle: ClientHandle[Any] = ClientHandle(
189
+ client=session,
190
+ adapter=self.name,
191
+ capabilities=CAPABILITIES,
192
+ report=plan.report,
193
+ runtime=runtime,
194
+ plan=plan,
195
+ close=session.close,
196
+ )
197
+ register_handle(session, handle)
198
+ return handle
199
+
200
+ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
201
+ raise UnsupportedCapabilityError(
202
+ "requests is sync-only: there is no async client to build; use build_sync (or the httpx/aiohttp adapters)"
203
+ )
204
+
205
+
206
+ __all__ = ["EngineHTTPAdapter", "RequestsAdapter"]
@@ -0,0 +1,80 @@
1
+ """requests capability declaration. Zero-dependency: never imports requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.capabilities import (
6
+ AdapterCapabilities,
7
+ Capability,
8
+ DurationBoundary,
9
+ SeamGranularity,
10
+ Support,
11
+ )
12
+ from ...core.model import FailureKind
13
+
14
+ CAPABILITIES = AdapterCapabilities(
15
+ adapter="requests",
16
+ seam="http_adapter",
17
+ granularity=SeamGranularity.HOP,
18
+ boundary=DurationBoundary.HEADERS,
19
+ support={
20
+ Capability.TIMEOUT_TOTAL: Support.EMULATED,
21
+ Capability.TIMEOUT_ATTEMPT: Support.ABSENT,
22
+ Capability.TIMEOUT_CONNECT: Support.NATIVE,
23
+ Capability.TIMEOUT_READ: Support.NATIVE,
24
+ Capability.TIMEOUT_WRITE: Support.ABSENT,
25
+ Capability.TIMEOUT_POOL: Support.ABSENT,
26
+ Capability.DEADLINE_HARD: Support.ABSENT,
27
+ Capability.POOL_LIMIT_TOTAL: Support.ABSENT,
28
+ Capability.POOL_LIMIT_PER_HOST: Support.NATIVE,
29
+ Capability.KEEPALIVE: Support.DEGRADED,
30
+ Capability.POOL_METRICS: Support.ABSENT,
31
+ Capability.CONN_METRICS: Support.ABSENT,
32
+ Capability.REDIRECTS_OWNABLE: Support.NATIVE,
33
+ Capability.NATIVE_RETRY_DISABLEABLE: Support.NATIVE,
34
+ Capability.PER_CALL_OPTIONS: Support.EMULATED,
35
+ Capability.RETROFIT: Support.ABSENT,
36
+ Capability.EXACT_NATIVE_TYPE: Support.NATIVE,
37
+ Capability.BALANCER: Support.ABSENT,
38
+ Capability.HTTP2: Support.ABSENT,
39
+ Capability.HTTP3: Support.ABSENT,
40
+ Capability.PROXY: Support.NATIVE,
41
+ },
42
+ emits=frozenset(
43
+ {
44
+ FailureKind.CONNECT_TIMEOUT,
45
+ FailureKind.READ_TIMEOUT,
46
+ FailureKind.TOTAL_TIMEOUT,
47
+ FailureKind.CONNECT_ERROR,
48
+ FailureKind.DNS_ERROR,
49
+ FailureKind.TLS_ERROR,
50
+ FailureKind.DISCONNECTED,
51
+ FailureKind.BODY_ERROR,
52
+ FailureKind.STATUS,
53
+ FailureKind.CIRCUIT_OPEN,
54
+ FailureKind.UNKNOWN,
55
+ }
56
+ ),
57
+ collapses={
58
+ FailureKind.POOL_TIMEOUT: FailureKind.CONNECT_TIMEOUT,
59
+ FailureKind.PROTOCOL_ERROR: FailureKind.DISCONNECTED,
60
+ FailureKind.WRITE_TIMEOUT: FailureKind.TOTAL_TIMEOUT,
61
+ },
62
+ notes={
63
+ "sync_only": "requests has no async client; build_async raises.",
64
+ "no_session_timeout": (
65
+ "requests has NO session-level timeout default - a bare session.get() hangs forever. The engine "
66
+ "closes that hole: every attempt is sent with the planned (connect, read) tuple."
67
+ ),
68
+ "timeout_pool": "HTTPAdapter.send never forwards pool_timeout; a saturated blocking pool waits inside connect.",
69
+ "pool_limits": (
70
+ "requests pools are per-host (pool_maxsize); there is no global cap - pool_connections is an LRU of "
71
+ "host pools, not a limit."
72
+ ),
73
+ "keepalive": "Connections stay alive per urllib3 defaults; expiry and keep-alive count are not controllable.",
74
+ "base_url": "requests has no base_url; the build rejects a config that sets one.",
75
+ "per_call_options": "No request extensions; route/idempotency travel via the call_options() context manager.",
76
+ "protocol_error": "requests folds protocol violations into ConnectionError; they surface as disconnected.",
77
+ },
78
+ )
79
+
80
+ __all__ = ["CAPABILITIES"]
@@ -0,0 +1,62 @@
1
+ """requests exception -> FailureKind classification.
2
+
3
+ requests rewrites most urllib3 failures into its own hierarchy and folds
4
+ protocol violations into ConnectionError; the cause chain is inspected to tell
5
+ a DNS failure or a mid-stream disconnect from a refused connection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import http.client
11
+ import ssl
12
+
13
+ from ...core.model import FailureKind
14
+ from ._imports import requests, urllib3
15
+
16
+
17
+ def _cause_chain(exc: BaseException) -> list[BaseException]:
18
+ seen: set[int] = set()
19
+ chain: list[BaseException] = []
20
+ current: BaseException | None = exc
21
+ while current is not None and id(current) not in seen:
22
+ seen.add(id(current))
23
+ chain.append(current)
24
+ for nested in getattr(current, "args", ()):
25
+ if isinstance(nested, BaseException) and id(nested) not in seen:
26
+ chain.append(nested)
27
+ current = current.__cause__ or current.__context__
28
+ return chain
29
+
30
+
31
+ def _classify_connection_error(exc: requests.exceptions.ConnectionError) -> FailureKind:
32
+ for cause in _cause_chain(exc):
33
+ if isinstance(cause, urllib3.exceptions.NameResolutionError):
34
+ return FailureKind.DNS_ERROR
35
+ if isinstance(cause, (urllib3.exceptions.ProtocolError, http.client.RemoteDisconnected, ConnectionResetError)):
36
+ return FailureKind.DISCONNECTED
37
+ if isinstance(cause, (ssl.SSLError, ssl.CertificateError)):
38
+ return FailureKind.TLS_ERROR
39
+ return FailureKind.CONNECT_ERROR
40
+
41
+
42
+ def classify_error(exc: BaseException) -> FailureKind:
43
+ if isinstance(exc, requests.exceptions.ConnectTimeout):
44
+ return FailureKind.CONNECT_TIMEOUT
45
+ if isinstance(exc, requests.exceptions.ReadTimeout):
46
+ return FailureKind.READ_TIMEOUT
47
+ if isinstance(exc, requests.exceptions.Timeout):
48
+ return FailureKind.TOTAL_TIMEOUT
49
+ if isinstance(exc, requests.exceptions.SSLError):
50
+ return FailureKind.TLS_ERROR
51
+ if isinstance(exc, requests.exceptions.ProxyError):
52
+ return FailureKind.CONNECT_ERROR
53
+ if isinstance(exc, (requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError)):
54
+ return FailureKind.BODY_ERROR
55
+ if isinstance(exc, requests.exceptions.ConnectionError):
56
+ return _classify_connection_error(exc)
57
+ if isinstance(exc, TimeoutError):
58
+ return FailureKind.TOTAL_TIMEOUT
59
+ return FailureKind.UNKNOWN
60
+
61
+
62
+ __all__ = ["classify_error"]
@@ -0,0 +1,40 @@
1
+ """Kernel errors dual-inherited into the requests family.
2
+
3
+ A user's ``except requests.RequestException`` (or ``except requests.Timeout``)
4
+ keeps working when clientwright raises on its own authority.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
10
+ from ._imports import requests
11
+
12
+
13
+ class RequestsCircuitOpenError(CircuitOpenError, requests.RequestException):
14
+ """Circuit open, catchable as requests.RequestException."""
15
+
16
+
17
+ class RequestsDeadlineExceededError(DeadlineExceededError, requests.Timeout):
18
+ """Total deadline exhausted, catchable as requests.Timeout."""
19
+
20
+
21
+ class RequestsTooManyRedirectsError(TooManyRedirectsError, requests.TooManyRedirects):
22
+ """Owned redirect limit exceeded, catchable as requests.TooManyRedirects."""
23
+
24
+
25
+ def translate_call_error(error: CallError) -> BaseException:
26
+ if isinstance(error, CircuitOpenError):
27
+ return RequestsCircuitOpenError(error.key, error.retry_after)
28
+ if isinstance(error, DeadlineExceededError):
29
+ return RequestsDeadlineExceededError(error.total)
30
+ if isinstance(error, TooManyRedirectsError):
31
+ return RequestsTooManyRedirectsError(error.hops)
32
+ return error
33
+
34
+
35
+ __all__ = [
36
+ "RequestsCircuitOpenError",
37
+ "RequestsDeadlineExceededError",
38
+ "RequestsTooManyRedirectsError",
39
+ "translate_call_error",
40
+ ]
@@ -0,0 +1,63 @@
1
+ """Sync normalizer: the whole requests-specific contract for the sync engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from ...core.contracts.message import RequestView, ResponseView
9
+ from ...core.engine.base import default_response_outcome
10
+ from ...core.model import ConnMetrics, FailureKind, Outcome
11
+ from ._imports import requests
12
+ from .classify import classify_error
13
+ from .views import RequestsRequestView, RequestsResponseView
14
+
15
+
16
+ class SyncRequestsNormalizer:
17
+ def wrap_request(self, native: Any) -> RequestsRequestView:
18
+ return RequestsRequestView(native)
19
+
20
+ def wrap_response(self, native: Any) -> RequestsResponseView:
21
+ return RequestsResponseView(native)
22
+
23
+ def classify_error(self, exc: BaseException) -> FailureKind:
24
+ return classify_error(exc)
25
+
26
+ def classify_response(self, response: ResponseView) -> Outcome:
27
+ return default_response_outcome(response)
28
+
29
+ def freeze(self, request: RequestView) -> bool:
30
+ """Bytes and strings replay for free; seekable streams rewind, the rest do not."""
31
+ body = request.native.body
32
+ if body is None or isinstance(body, (bytes, str)):
33
+ return True
34
+ position = getattr(request.native, "_body_position", None)
35
+ return isinstance(position, int)
36
+
37
+ def rewind(self, request: RequestView) -> None:
38
+ body = request.native.body
39
+ if body is None or isinstance(body, (bytes, str)):
40
+ return None
41
+ try:
42
+ requests.utils.rewind_body(request.native)
43
+ except requests.exceptions.UnrewindableBodyError: # pragma: no cover - freeze() gated this
44
+ return None
45
+ return None
46
+
47
+ def discard(self, response: ResponseView) -> None:
48
+ """MANDATORY before a repeat - otherwise the connection never returns to the pool."""
49
+ try:
50
+ response.native.close()
51
+ except Exception:
52
+ return None
53
+
54
+ def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
55
+ # Session.send consumes the body ABOVE this seam (unless stream=True);
56
+ # body read is not instrumented. Declared in capabilities.
57
+ return None
58
+
59
+ def conn_metrics(self, response: ResponseView) -> ConnMetrics | None:
60
+ return None
61
+
62
+
63
+ __all__ = ["SyncRequestsNormalizer"]
@@ -0,0 +1,121 @@
1
+ """RequestView/ResponseView implementations over requests objects.
2
+
3
+ The caller's per-request ``timeout=`` is not stored on the PreparedRequest;
4
+ the engine adapter receives it as a ``send()`` argument and parks it on the
5
+ request object before wrapping, which is where the view picks it up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import MutableMapping
11
+ from typing import cast
12
+ from urllib.parse import urlsplit
13
+
14
+ from ...core.model import IDEMPOTENT_METHODS, RequestInfo, ResolvedTimeouts, origin_of
15
+ from ...core.options import current_call_options
16
+ from ._imports import requests
17
+
18
+ # Attribute the engine adapter sets on the PreparedRequest before wrapping.
19
+ CALLER_TIMEOUT_ATTRIBUTE = "_clientwright_caller_timeout"
20
+
21
+ _BODY_HEADERS = ("Content-Length", "Content-Type", "Transfer-Encoding")
22
+
23
+
24
+ def caller_timeouts_from(timeout: object) -> ResolvedTimeouts | None:
25
+ """Translate requests' ``timeout=`` forms: float, or (connect, read)."""
26
+ if timeout is None:
27
+ return None
28
+ if isinstance(timeout, (int, float)):
29
+ value = float(timeout)
30
+ return ResolvedTimeouts(connect=value, read=value)
31
+ if isinstance(timeout, tuple) and len(timeout) == 2:
32
+ connect, read = timeout
33
+ return ResolvedTimeouts(
34
+ connect=float(connect) if connect is not None else None,
35
+ read=float(read) if read is not None else None,
36
+ )
37
+ return None
38
+
39
+
40
+ class RequestsRequestView:
41
+ __slots__ = ("_caller", "_options", "_request", "planned")
42
+
43
+ def __init__(self, request: requests.PreparedRequest) -> None:
44
+ self._request = request
45
+ self._options = current_call_options()
46
+ self._caller = caller_timeouts_from(getattr(request, CALLER_TIMEOUT_ATTRIBUTE, None))
47
+ # The engine's per-attempt plan; the send callback translates it into
48
+ # the (connect, read) tuple HTTPAdapter.send accepts.
49
+ self.planned: ResolvedTimeouts | None = None
50
+
51
+ @property
52
+ def native(self) -> requests.PreparedRequest:
53
+ return self._request
54
+
55
+ @property
56
+ def info(self) -> RequestInfo:
57
+ request = self._request
58
+ method = request.method or "GET"
59
+ url = request.url or ""
60
+ options = self._options
61
+ route = options.route if options is not None else None
62
+ if options is not None and options.idempotent is not None:
63
+ idempotent = options.idempotent
64
+ else:
65
+ idempotent = method in IDEMPOTENT_METHODS
66
+ return RequestInfo(method=method, origin=origin_of(url), url=url, route=route, idempotent=idempotent)
67
+
68
+ @property
69
+ def headers(self) -> MutableMapping[str, str]:
70
+ return cast(MutableMapping[str, str], self._request.headers)
71
+
72
+ def caller_timeouts(self) -> ResolvedTimeouts | None:
73
+ return self._caller
74
+
75
+ def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
76
+ self.planned = timeouts
77
+
78
+ def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
79
+ request = self._request
80
+ request.url = url
81
+ # requests never sets a Host header itself (http.client derives it from
82
+ # the URL), but a caller-set one must not leak to the new host.
83
+ if "Host" in request.headers:
84
+ request.headers["Host"] = urlsplit(url).netloc
85
+ if method is not None:
86
+ request.method = method
87
+ if drop_body:
88
+ for name in _BODY_HEADERS:
89
+ request.headers.pop(name, None)
90
+ request.body = None
91
+
92
+
93
+ class RequestsResponseView:
94
+ __slots__ = ("_response",)
95
+
96
+ def __init__(self, response: requests.Response) -> None:
97
+ self._response = response
98
+
99
+ @property
100
+ def native(self) -> requests.Response:
101
+ return self._response
102
+
103
+ @property
104
+ def status_code(self) -> int:
105
+ return self._response.status_code
106
+
107
+ def header(self, name: str) -> str | None:
108
+ value = self._response.headers.get(name)
109
+ return value if isinstance(value, str) else None
110
+
111
+ @property
112
+ def location(self) -> str | None:
113
+ return self.header("Location")
114
+
115
+
116
+ __all__ = [
117
+ "CALLER_TIMEOUT_ATTRIBUTE",
118
+ "RequestsRequestView",
119
+ "RequestsResponseView",
120
+ "caller_timeouts_from",
121
+ ]
@@ -0,0 +1,48 @@
1
+ """urllib3 adapter (``[urllib3]`` extra): real PoolManager, engine underneath.
2
+
3
+ SDK-backed names resolve lazily (see ``clientwright.adapters._lazy``): importing
4
+ this package never imports urllib3, so the capabilities matrix stays
5
+ extras-free. The per-call channel comes from the zero-dependency core and is
6
+ therefore eager.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from ...core.options import CallOptions as CallOptions
14
+ from ...core.options import call_options as call_options
15
+ from .._lazy import lazy_attribute
16
+
17
+ if TYPE_CHECKING:
18
+ from .adapter import Urllib3Adapter as Urllib3Adapter
19
+ from .adapter import translate_retry as translate_retry
20
+ from .capabilities import CAPABILITIES as CAPABILITIES
21
+ from .errors import Urllib3CircuitOpenError as Urllib3CircuitOpenError
22
+ from .errors import Urllib3DeadlineExceededError as Urllib3DeadlineExceededError
23
+ from .errors import Urllib3TooManyRedirectsError as Urllib3TooManyRedirectsError
24
+
25
+ _EXPORTS = {
26
+ "CAPABILITIES": "capabilities",
27
+ "Urllib3Adapter": "adapter",
28
+ "Urllib3CircuitOpenError": "errors",
29
+ "Urllib3DeadlineExceededError": "errors",
30
+ "Urllib3TooManyRedirectsError": "errors",
31
+ "translate_retry": "adapter",
32
+ }
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
37
+
38
+
39
+ __all__ = [
40
+ "CAPABILITIES",
41
+ "CallOptions",
42
+ "Urllib3Adapter",
43
+ "Urllib3CircuitOpenError",
44
+ "Urllib3DeadlineExceededError",
45
+ "Urllib3TooManyRedirectsError",
46
+ "call_options",
47
+ "translate_retry",
48
+ ]
@@ -0,0 +1,18 @@
1
+ """Lazy import guard for the optional urllib3 stack.
2
+
3
+ Importing this module fails with a friendly message when the ``urllib3`` extra
4
+ is not installed. Every urllib3 submodule imports its third-party symbols from
5
+ here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _INSTALL_HINT = "urllib3 support requires clientwright[urllib3]; install it."
11
+
12
+ try:
13
+ import urllib3
14
+ from urllib3.util import Retry, Timeout
15
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
16
+ raise ImportError(_INSTALL_HINT) from exc
17
+
18
+ __all__ = ["Retry", "Timeout", "urllib3"]