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,241 @@
1
+ """The single synchronous attempt loop; mirrors AsyncAttemptEngine.
2
+
3
+ Sync runtimes cannot cancel a blocked socket call, so the total deadline is
4
+ SOFT here: per-attempt phases are clamped by the remaining budget and the
5
+ deadline is re-checked at attempt boundaries. Adapters declare this as
6
+ ``DEADLINE_HARD: ABSENT``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable
14
+ from contextlib import ExitStack
15
+ from typing import Any
16
+
17
+ from ..contracts.adapter import AdapterDeps
18
+ from ..contracts.message import RequestView, ResponseView, SyncNormalizer
19
+ from ..errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
20
+ from ..model import Attempt, FailureKind, Outcome
21
+ from ..plan import CallPlan, ClientRuntime
22
+ from ..policy.budget import Deadline
23
+ from ..telemetry.emitter import CallObservation, ClientTelemetry
24
+ from .base import SKIP_REASONS, deadline_header_value, inject_static_headers
25
+ from .redirects import apply_redirect, plan_redirect
26
+
27
+ type SyncSend = Callable[[RequestView], Any]
28
+ type ErrorTranslator = Callable[[CallError], BaseException]
29
+
30
+ logger = logging.getLogger("clientwright.engine")
31
+
32
+
33
+ class SyncAttemptEngine:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ plan: CallPlan,
38
+ runtime: ClientRuntime,
39
+ telemetry: ClientTelemetry,
40
+ normalizer: SyncNormalizer,
41
+ deps: AdapterDeps,
42
+ translate: ErrorTranslator,
43
+ ) -> None:
44
+ self._plan = plan
45
+ self._runtime = runtime
46
+ self._telemetry = telemetry
47
+ self._norm = normalizer
48
+ self._deps = deps
49
+ self._translate = translate
50
+
51
+ def run(self, native_request: Any, send: SyncSend) -> Any:
52
+ clock = self._runtime.clock
53
+ request = self._norm.wrap_request(native_request)
54
+ info = request.info
55
+ started = clock()
56
+ observation = self._telemetry.call_start(info, started)
57
+ final_outcome = Outcome(kind=FailureKind.UNKNOWN)
58
+ response: ResponseView | None = None
59
+ try:
60
+ response, final_outcome = self._admitted(request, send, observation)
61
+ except CallError as error:
62
+ final_outcome = self._call_error_outcome(error)
63
+ raise self._translate(error) from error
64
+ except BaseException as error:
65
+ if final_outcome.exception is not error:
66
+ final_outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
67
+ raise
68
+ finally:
69
+ self._telemetry.call_end(observation, info, final_outcome, clock() - started)
70
+ if final_outcome.exception is not None:
71
+ raise final_outcome.exception
72
+ assert response is not None
73
+ self._wrap_stream(response, info)
74
+ return response.native
75
+
76
+ def _call_error_outcome(self, error: CallError) -> Outcome:
77
+ if isinstance(error, CircuitOpenError):
78
+ return Outcome(kind=FailureKind.CIRCUIT_OPEN, exception=error)
79
+ if isinstance(error, DeadlineExceededError):
80
+ return Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
81
+ return Outcome(kind=FailureKind.UNKNOWN, exception=error)
82
+
83
+ def _wrap_stream(self, response: ResponseView, info: Any) -> None:
84
+ def on_done(outcome: Outcome, duration: float) -> None:
85
+ self._telemetry.record_body_duration(info, duration)
86
+
87
+ self._norm.wrap_stream(response, on_done)
88
+
89
+ def _inject_headers(self, request: RequestView) -> None:
90
+ headers = request.headers
91
+ inject_static_headers(headers, dict(self._plan.config.headers))
92
+ for provider in self._deps.header_providers:
93
+ try:
94
+ inject_static_headers(headers, dict(provider()))
95
+ except Exception:
96
+ logger.debug("Header provider %r failed", provider, exc_info=True)
97
+ continue
98
+ self._telemetry.tracer.inject_context(headers)
99
+
100
+ def _deadline(self) -> Deadline:
101
+ ambient = self._deps.deadline_source.remaining() if self._deps.deadline_source is not None else None
102
+ return Deadline.intersect(self._runtime.clock, self._plan.config.timeout.total, ambient)
103
+
104
+ def _admitted(
105
+ self, request: RequestView, send: SyncSend, observation: CallObservation
106
+ ) -> tuple[ResponseView | None, Outcome]:
107
+ plan = self._plan
108
+ runtime = self._runtime
109
+ info = request.info
110
+ deadline = self._deadline()
111
+ self._inject_headers(request)
112
+ circuit_key: str | None = None
113
+ with ExitStack() as stack:
114
+ if plan.use_origin_limiter and runtime.sync_limiter is not None:
115
+ stack.enter_context(runtime.sync_limiter.acquire(info.origin))
116
+ if runtime.circuits is not None and plan.config.circuit_breaker is not None:
117
+ circuit_key = info.circuit_key(plan.config.circuit_breaker.key)
118
+ runtime.circuits.check(circuit_key)
119
+ if runtime.retry_budgets is not None:
120
+ runtime.retry_budgets.earn(info.origin)
121
+ outcome = Outcome(kind=FailureKind.UNKNOWN)
122
+ aborted = False
123
+ try:
124
+ response, outcome = self._hops(request, send, deadline, observation)
125
+ except CallError as error:
126
+ outcome = self._call_error_outcome(error)
127
+ raise
128
+ except BaseException:
129
+ # No classified outcome: neither success nor failure for the breaker.
130
+ aborted = True
131
+ raise
132
+ else:
133
+ return response, outcome
134
+ finally:
135
+ if circuit_key is not None and runtime.circuits is not None:
136
+ if aborted:
137
+ runtime.circuits.record_aborted(circuit_key)
138
+ else:
139
+ runtime.circuits.record(circuit_key, outcome)
140
+ raise AssertionError("unreachable") # pragma: no cover - ExitStack never swallows
141
+
142
+ def _hops(
143
+ self, request: RequestView, send: SyncSend, deadline: Deadline, observation: CallObservation
144
+ ) -> tuple[ResponseView | None, Outcome]:
145
+ plan = self._plan
146
+ replayable = self._freeze_if_needed(request)
147
+ history: list[Attempt] = []
148
+ while True:
149
+ response, outcome = self._attempts(request, send, deadline, observation, history, replayable)
150
+ if response is None or outcome.exception is not None:
151
+ return response, outcome
152
+ if plan.config.redirects.value != "owned":
153
+ return response, outcome
154
+ step = plan_redirect(response, request)
155
+ if step is None:
156
+ return response, outcome
157
+ if observation.hops >= plan.config.max_redirects:
158
+ raise TooManyRedirectsError(plan.config.max_redirects)
159
+ if step.needs_body_replay and not replayable:
160
+ return response, outcome
161
+ self._norm.discard(response)
162
+ apply_redirect(request, step)
163
+ self._telemetry.redirect_hop(observation)
164
+
165
+ def _freeze_if_needed(self, request: RequestView) -> bool:
166
+ plan = self._plan
167
+ needs_replay = plan.retry_policy is not None or plan.config.redirects.value == "owned"
168
+ if not needs_replay:
169
+ return True
170
+ return self._norm.freeze(request)
171
+
172
+ def _attempts(
173
+ self,
174
+ request: RequestView,
175
+ send: SyncSend,
176
+ deadline: Deadline,
177
+ observation: CallObservation,
178
+ history: list[Attempt],
179
+ replayable: bool,
180
+ ) -> tuple[ResponseView | None, Outcome]:
181
+ plan = self._plan
182
+ runtime = self._runtime
183
+ info = request.info
184
+ caller = request.caller_timeouts()
185
+ while True:
186
+ remaining = deadline.remaining()
187
+ if remaining is not None and remaining <= 0:
188
+ raise DeadlineExceededError(deadline.total or 0.0)
189
+ timeouts = plan.planner.plan(remaining=remaining, caller=caller)
190
+ request.apply_timeouts(timeouts)
191
+ if plan.config.deadline_header is not None and remaining is not None:
192
+ request.headers[plan.config.deadline_header] = deadline_header_value(remaining)
193
+ attempt_started = runtime.clock()
194
+ observation.attempts += 1
195
+ response: ResponseView | None = None
196
+ try:
197
+ native_response = send(request)
198
+ response = self._norm.wrap_response(native_response)
199
+ outcome = self._norm.classify_response(response)
200
+ except CallError:
201
+ raise
202
+ except TimeoutError as error:
203
+ outcome = Outcome(kind=FailureKind.TOTAL_TIMEOUT, exception=error)
204
+ except Exception as error:
205
+ outcome = Outcome(kind=self._norm.classify_error(error), exception=error)
206
+ attempt = Attempt(
207
+ index=len(history) + 1,
208
+ started=attempt_started,
209
+ duration=runtime.clock() - attempt_started,
210
+ outcome=outcome,
211
+ hop=observation.hops,
212
+ )
213
+ history.append(attempt)
214
+ if plan.emit_attempt_metrics:
215
+ self._telemetry.attempt_end(info, attempt)
216
+ if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
217
+ raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
218
+ if plan.retry_policy is None:
219
+ return response, outcome
220
+ decision = plan.retry_policy.decide(
221
+ info=info,
222
+ history=history,
223
+ remaining=deadline.remaining(),
224
+ replayable=replayable,
225
+ rng=runtime.rng,
226
+ )
227
+ if not decision.retry:
228
+ if decision.reason in SKIP_REASONS:
229
+ self._telemetry.retry_skipped(decision.reason)
230
+ return response, outcome
231
+ if runtime.retry_budgets is not None and not runtime.retry_budgets.try_spend(info.origin):
232
+ self._telemetry.retry_skipped("budget")
233
+ return response, outcome
234
+ if response is not None:
235
+ self._norm.discard(response)
236
+ self._norm.rewind(request)
237
+ if decision.delay > 0:
238
+ time.sleep(decision.delay)
239
+
240
+
241
+ __all__ = ["SyncAttemptEngine", "SyncSend"]
@@ -0,0 +1,113 @@
1
+ """Kernel error hierarchy.
2
+
3
+ Errors raised on the call path (``CallError`` subclasses) are translated by each
4
+ adapter into classes that ALSO inherit the native client's error family, so a
5
+ user's ``except httpx.HTTPError`` keeps working. Errors raised at build time are
6
+ plain kernel errors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class ClientwrightError(Exception):
13
+ """Base class for everything clientwright raises on its own authority."""
14
+
15
+
16
+ class UnknownAdapterError(ClientwrightError):
17
+ """Requested adapter name is not registered."""
18
+
19
+ def __init__(self, name: str, known: tuple[str, ...]) -> None:
20
+ super().__init__(f"Unknown adapter {name!r}; registered adapters: {', '.join(known) or '<none>'}")
21
+ self.name = name
22
+ self.known = known
23
+
24
+
25
+ class UnsupportedCapabilityError(ClientwrightError):
26
+ """Config asks for something the chosen adapter cannot express (STRICT mode)."""
27
+
28
+
29
+ class NativeConfigError(ClientwrightError):
30
+ """Base for invalid native passthrough configuration."""
31
+
32
+
33
+ class UnknownNativeSlotError(NativeConfigError):
34
+ def __init__(self, slot: str, known: tuple[str, ...]) -> None:
35
+ super().__init__(f"Unknown native slot {slot!r}; adapter slots: {', '.join(known)}")
36
+ self.slot = slot
37
+ self.known = known
38
+
39
+
40
+ class ReservedNativeKeyError(NativeConfigError):
41
+ def __init__(self, slot: str, key: str, hint: str) -> None:
42
+ super().__init__(f"Native key {key!r} in slot {slot!r} is owned by clientwright: {hint}")
43
+ self.slot = slot
44
+ self.key = key
45
+
46
+
47
+ class UnknownNativeKeyError(NativeConfigError):
48
+ def __init__(self, slot: str, key: str, suggestion: str | None = None) -> None:
49
+ hint = f"; did you mean {suggestion!r}?" if suggestion else ""
50
+ super().__init__(f"Native slot {slot!r} accepts no parameter {key!r}{hint}")
51
+ self.slot = slot
52
+ self.key = key
53
+ self.suggestion = suggestion
54
+
55
+
56
+ class NativeConfigConflictError(NativeConfigError):
57
+ def __init__(self, slot: str, key: str, config_field: str) -> None:
58
+ super().__init__(
59
+ f"Native key {key!r} in slot {slot!r} conflicts with explicitly set config field {config_field!r}; "
60
+ f"set it in exactly one place"
61
+ )
62
+ self.slot = slot
63
+ self.key = key
64
+ self.config_field = config_field
65
+
66
+
67
+ class CallError(ClientwrightError):
68
+ """Base for errors raised while executing a call; adapters dual-inherit these."""
69
+
70
+
71
+ class CircuitOpenError(CallError):
72
+ """Local refusal: the circuit for this key is open."""
73
+
74
+ def __init__(self, key: str, retry_after: float | None = None) -> None:
75
+ suffix = f" (retry in ~{retry_after:.1f}s)" if retry_after is not None else ""
76
+ super().__init__(f"Circuit open for {key!r}{suffix}")
77
+ self.key = key
78
+ self.retry_after = retry_after
79
+
80
+
81
+ class DeadlineExceededError(CallError):
82
+ """The total deadline of the logical call was exhausted."""
83
+
84
+ def __init__(self, total: float) -> None:
85
+ super().__init__(f"Deadline of {total:.3f}s exhausted")
86
+ self.total = total
87
+
88
+
89
+ class TooManyRedirectsError(CallError):
90
+ def __init__(self, hops: int) -> None:
91
+ super().__init__(f"Exceeded {hops} redirect hops")
92
+ self.hops = hops
93
+
94
+
95
+ class NotReplayableError(CallError):
96
+ """The request body cannot be replayed, so the required repeat is impossible."""
97
+
98
+
99
+ __all__ = [
100
+ "CallError",
101
+ "CircuitOpenError",
102
+ "ClientwrightError",
103
+ "DeadlineExceededError",
104
+ "NativeConfigConflictError",
105
+ "NativeConfigError",
106
+ "NotReplayableError",
107
+ "ReservedNativeKeyError",
108
+ "TooManyRedirectsError",
109
+ "UnknownAdapterError",
110
+ "UnknownNativeKeyError",
111
+ "UnknownNativeSlotError",
112
+ "UnsupportedCapabilityError",
113
+ ]
@@ -0,0 +1,138 @@
1
+ """Transport-neutral data model shared by engines, policies and adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from urllib.parse import urlsplit
8
+
9
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
10
+
11
+ # Methods whose semantics make a repeat safe without an idempotency key (RFC 9110).
12
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"})
13
+
14
+
15
+ class FailureKind(StrEnum):
16
+ """Shared alphabet of call outcomes.
17
+
18
+ An alphabet, not a shared partition: each adapter declares which kinds it
19
+ can actually emit (``AdapterCapabilities.emits``) and which finer kinds
20
+ collapse into coarser ones (``AdapterCapabilities.collapses``).
21
+ """
22
+
23
+ CONNECT_TIMEOUT = "connect_timeout"
24
+ READ_TIMEOUT = "read_timeout"
25
+ WRITE_TIMEOUT = "write_timeout"
26
+ POOL_TIMEOUT = "pool_timeout"
27
+ TOTAL_TIMEOUT = "total_timeout"
28
+ CONNECT_ERROR = "connect_error"
29
+ DNS_ERROR = "dns_error"
30
+ TLS_ERROR = "tls_error"
31
+ PROTOCOL_ERROR = "protocol_error"
32
+ DISCONNECTED = "disconnected"
33
+ BODY_ERROR = "body_error"
34
+ STATUS = "status"
35
+ CANCELLED = "cancelled"
36
+ CIRCUIT_OPEN = "circuit_open"
37
+ UNKNOWN = "unknown"
38
+
39
+
40
+ class CircuitKey(StrEnum):
41
+ """Granularity of the circuit-breaker key."""
42
+
43
+ ORIGIN = "origin"
44
+ ORIGIN_ROUTE = "origin_route"
45
+ ORIGIN_METHOD = "origin_method"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Outcome:
50
+ """Result of a single attempt. ``kind is None`` means success."""
51
+
52
+ kind: FailureKind | None
53
+ status_code: int | None = None
54
+ retry_after: float | None = None
55
+ exception: BaseException | None = None
56
+
57
+ @property
58
+ def ok(self) -> bool:
59
+ return self.kind is None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ResolvedTimeouts:
64
+ """Per-attempt timeout plan handed to the adapter before each send.
65
+
66
+ ``attempt`` is the hard ceiling of the whole attempt; async engines enforce
67
+ it with task cancellation, sync engines can only clamp the phases below.
68
+ """
69
+
70
+ connect: float | None = None
71
+ read: float | None = None
72
+ write: float | None = None
73
+ pool_acquire: float | None = None
74
+ attempt: float | None = None
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class ConnMetrics:
79
+ """Optional per-connection timings; ``None`` fields mean the adapter cannot see them."""
80
+
81
+ dns: float | None = None
82
+ connect: float | None = None
83
+ tls: float | None = None
84
+ pool_wait: float | None = None
85
+ reused: bool | None = None
86
+ http_version: str | None = None
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Attempt:
91
+ """One physical attempt inside a logical call."""
92
+
93
+ index: int
94
+ started: float
95
+ duration: float
96
+ outcome: Outcome
97
+ hop: int = 0
98
+ conn: ConnMetrics | None = None
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class RequestInfo:
103
+ """Low-cardinality identity of a logical call."""
104
+
105
+ method: str
106
+ origin: str
107
+ url: str
108
+ route: str | None = None
109
+ idempotent: bool = True
110
+
111
+ def circuit_key(self, mode: CircuitKey) -> str:
112
+ if mode is CircuitKey.ORIGIN_ROUTE:
113
+ return f"{self.origin} {self.route or 'unknown'}"
114
+ if mode is CircuitKey.ORIGIN_METHOD:
115
+ return f"{self.origin} {self.method}"
116
+ return self.origin
117
+
118
+
119
+ def origin_of(url: str) -> str:
120
+ """Normalize a URL to its origin: ``scheme://host:port`` lowercased, default ports explicit."""
121
+ parts = urlsplit(url)
122
+ scheme = (parts.scheme or "http").lower()
123
+ host = (parts.hostname or "").lower()
124
+ port = parts.port if parts.port is not None else _DEFAULT_PORTS.get(scheme)
125
+ return f"{scheme}://{host}:{port}" if port is not None else f"{scheme}://{host}"
126
+
127
+
128
+ __all__ = [
129
+ "IDEMPOTENT_METHODS",
130
+ "Attempt",
131
+ "CircuitKey",
132
+ "ConnMetrics",
133
+ "FailureKind",
134
+ "Outcome",
135
+ "RequestInfo",
136
+ "ResolvedTimeouts",
137
+ "origin_of",
138
+ ]
@@ -0,0 +1,84 @@
1
+ """Validation of raw native passthrough options.
2
+
3
+ ``PoolManager(**kw)``-style constructors swallow any typo silently; here every
4
+ unknown slot, reserved key, misspelled parameter and config collision is a
5
+ loud, actionable error at build time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import inspect
12
+ from collections.abc import Callable, Mapping
13
+
14
+ from .config import NativeOptions
15
+ from .errors import (
16
+ NativeConfigConflictError,
17
+ ReservedNativeKeyError,
18
+ UnknownNativeKeyError,
19
+ UnknownNativeSlotError,
20
+ )
21
+
22
+
23
+ def _suggest(key: str, candidates: list[str]) -> str | None:
24
+ matches = difflib.get_close_matches(key, candidates, n=1, cutoff=0.6)
25
+ return matches[0] if matches else None
26
+
27
+
28
+ def _signature_keys(target: Callable[..., object]) -> tuple[frozenset[str], bool]:
29
+ """Keyword-acceptable parameter names and whether **kwargs is present."""
30
+ signature = inspect.signature(target)
31
+ names = set()
32
+ has_var_keyword = False
33
+ for parameter in signature.parameters.values():
34
+ if parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
35
+ names.add(parameter.name)
36
+ elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
37
+ has_var_keyword = True
38
+ names.discard("self")
39
+ return frozenset(names), has_var_keyword
40
+
41
+
42
+ def validate_native(
43
+ native: NativeOptions,
44
+ *,
45
+ slots: frozenset[str],
46
+ reserved: Mapping[str, Mapping[str, str]],
47
+ allowed: Mapping[str, frozenset[str] | None],
48
+ signature_targets: Mapping[str, Callable[..., object]],
49
+ config_conflicts: Mapping[str, Mapping[str, str]],
50
+ ) -> dict[str, dict[str, object]]:
51
+ """Validate and return per-slot native kwargs.
52
+
53
+ ``config_conflicts`` maps slot -> {native_key: explicitly-set config field};
54
+ the adapter builds it from fields the user actually set (not UNSET), so the
55
+ "conflict is an error" rule fires exactly when both sides were specified.
56
+ """
57
+ validated: dict[str, dict[str, object]] = {}
58
+ for slot, values in native.slots.items():
59
+ if slot not in slots:
60
+ raise UnknownNativeSlotError(slot, tuple(sorted(slots)))
61
+ reserved_keys = reserved.get(slot, {})
62
+ allowlist = allowed.get(slot)
63
+ signature_keys: frozenset[str] | None = None
64
+ has_var_keyword = False
65
+ if allowlist is None and slot in signature_targets:
66
+ signature_keys, has_var_keyword = _signature_keys(signature_targets[slot])
67
+ conflicts = config_conflicts.get(slot, {})
68
+ slot_values: dict[str, object] = {}
69
+ for key, value in values.items():
70
+ if key in reserved_keys:
71
+ raise ReservedNativeKeyError(slot, key, reserved_keys[key])
72
+ if key in conflicts:
73
+ raise NativeConfigConflictError(slot, key, conflicts[key])
74
+ if allowlist is not None:
75
+ if key not in allowlist:
76
+ raise UnknownNativeKeyError(slot, key, _suggest(key, sorted(allowlist)))
77
+ elif signature_keys is not None and not has_var_keyword and key not in signature_keys:
78
+ raise UnknownNativeKeyError(slot, key, _suggest(key, sorted(signature_keys)))
79
+ slot_values[key] = value
80
+ validated[slot] = slot_values
81
+ return validated
82
+
83
+
84
+ __all__ = ["validate_native"]
@@ -0,0 +1,46 @@
1
+ """Ambient per-call options for adapters without a native per-request channel.
2
+
3
+ httpx carries route/idempotency in ``request.extensions``; aiohttp, requests
4
+ and urllib3 have no such container, so the options travel through a ContextVar
5
+ set by the caller around the call. A task or thread-local context started
6
+ under ``call_options`` inherits it; siblings do not.
7
+
8
+ with call_options(route="/users/{id}", idempotent=True):
9
+ session.post(f"/users/{user_id}")
10
+
11
+ One shared channel on purpose: the block applies to whichever clientwright
12
+ client sends inside it, uniformly across adapters.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Generator
18
+ from contextlib import contextmanager
19
+ from contextvars import ContextVar
20
+ from dataclasses import dataclass
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CallOptions:
25
+ route: str | None = None
26
+ idempotent: bool | None = None
27
+
28
+
29
+ _CALL_OPTIONS: ContextVar[CallOptions | None] = ContextVar("clientwright_call_options", default=None)
30
+
31
+
32
+ def current_call_options() -> CallOptions | None:
33
+ return _CALL_OPTIONS.get()
34
+
35
+
36
+ @contextmanager
37
+ def call_options(*, route: str | None = None, idempotent: bool | None = None) -> Generator[CallOptions]:
38
+ options = CallOptions(route=route, idempotent=idempotent)
39
+ token = _CALL_OPTIONS.set(options)
40
+ try:
41
+ yield options
42
+ finally:
43
+ _CALL_OPTIONS.reset(token)
44
+
45
+
46
+ __all__ = ["CallOptions", "call_options", "current_call_options"]