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,367 @@
1
+ """Universal, transport-free client configuration.
2
+
3
+ Three rules keep this config honest:
4
+
5
+ 1. ``UNSET`` is not "disabled": an unset knob defers to the adapter's native
6
+ default and the deferral is visible in the ``ConfigApplicationReport``.
7
+ 2. No transport types appear here, so the same config is readable by any adapter.
8
+ 3. Anything set but inexpressible on the chosen adapter lands in
9
+ ``report.dropped``; under ``UnsupportedPolicy.STRICT`` that fails the build.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass, field
17
+ from enum import StrEnum
18
+ from typing import Final
19
+
20
+ from .model import IDEMPOTENT_METHODS, CircuitKey, FailureKind
21
+
22
+
23
+ class _Unset:
24
+ """Singleton sentinel distinguishing "user set this" from "default applies"."""
25
+
26
+ __slots__ = ()
27
+ _instance: _Unset | None = None
28
+
29
+ def __new__(cls) -> _Unset:
30
+ if cls._instance is None:
31
+ cls._instance = super().__new__(cls)
32
+ return cls._instance
33
+
34
+ def __bool__(self) -> bool:
35
+ return False
36
+
37
+ def __repr__(self) -> str:
38
+ return "UNSET"
39
+
40
+
41
+ UNSET: Final = _Unset()
42
+
43
+ type Maybe[T] = T | _Unset
44
+
45
+
46
+ def is_set(value: object) -> bool:
47
+ """True when the value was explicitly provided (is not the UNSET sentinel)."""
48
+ return not isinstance(value, _Unset)
49
+
50
+
51
+ def resolve[T](value: Maybe[T], fallback: T) -> T:
52
+ """Collapse a Maybe to a concrete value."""
53
+ if isinstance(value, _Unset):
54
+ return fallback
55
+ return value
56
+
57
+
58
+ class UnsupportedPolicy(StrEnum):
59
+ IGNORE = "ignore"
60
+ WARN = "warn"
61
+ STRICT = "strict"
62
+
63
+
64
+ class RedirectMode(StrEnum):
65
+ OWNED = "owned"
66
+ NATIVE = "native"
67
+
68
+
69
+ class CallerOverride(StrEnum):
70
+ CALLER_WINS = "caller_wins"
71
+ CONFIG_WINS = "config_wins"
72
+ RAISE = "raise"
73
+
74
+
75
+ class RetryMode(StrEnum):
76
+ OWNED = "owned"
77
+ DELEGATED = "delegated"
78
+
79
+
80
+ DEFAULT_RETRYABLE_KINDS: Final = frozenset(
81
+ {
82
+ FailureKind.CONNECT_TIMEOUT,
83
+ FailureKind.CONNECT_ERROR,
84
+ FailureKind.DNS_ERROR,
85
+ FailureKind.POOL_TIMEOUT,
86
+ FailureKind.READ_TIMEOUT,
87
+ FailureKind.DISCONNECTED,
88
+ }
89
+ )
90
+
91
+ DEFAULT_RETRYABLE_STATUS: Final = frozenset({429, 502, 503, 504})
92
+
93
+ DEFAULT_TRIP_KINDS: Final = frozenset(
94
+ {
95
+ FailureKind.CONNECT_TIMEOUT,
96
+ FailureKind.READ_TIMEOUT,
97
+ FailureKind.WRITE_TIMEOUT,
98
+ FailureKind.POOL_TIMEOUT,
99
+ FailureKind.TOTAL_TIMEOUT,
100
+ FailureKind.CONNECT_ERROR,
101
+ FailureKind.DNS_ERROR,
102
+ FailureKind.TLS_ERROR,
103
+ FailureKind.DISCONNECTED,
104
+ FailureKind.PROTOCOL_ERROR,
105
+ FailureKind.STATUS,
106
+ }
107
+ )
108
+
109
+ DEFAULT_SENSITIVE_HEADERS: Final = frozenset(
110
+ {
111
+ "authorization",
112
+ "proxy-authorization",
113
+ "cookie",
114
+ "set-cookie",
115
+ "x-api-key",
116
+ "x-auth-token",
117
+ "api-key",
118
+ }
119
+ )
120
+
121
+ DEFAULT_SENSITIVE_QUERY_PARAMS: Final = frozenset(
122
+ {
123
+ "access_token",
124
+ "api_key",
125
+ "apikey",
126
+ "client_secret",
127
+ "code",
128
+ "password",
129
+ "refresh_token",
130
+ "secret",
131
+ "token",
132
+ }
133
+ )
134
+
135
+
136
+ def _require_positive(name: str, value: object) -> None:
137
+ if isinstance(value, _Unset) or value is None:
138
+ return
139
+ if isinstance(value, (int, float)) and value <= 0:
140
+ raise ValueError(f"{name} must be positive, got {value!r}")
141
+
142
+
143
+ def _coerce_enum[EnumT: StrEnum](config: object, field: str, enum: type[EnumT]) -> None:
144
+ """Turn a plain string into the real enum member, in place.
145
+
146
+ These fields are ``StrEnum``, so ``"delegated"`` looks like it works - but
147
+ the engine compares members with ``is``, and a bare string is never the same
148
+ object as a member. Config loaded from YAML or the environment would then
149
+ match no branch at all and silently take the fallback. Coercing here also
150
+ turns a typo into a ValueError at construction instead of a knob that
151
+ quietly does nothing.
152
+ """
153
+ value = getattr(config, field)
154
+ if type(value) is enum:
155
+ return
156
+ try:
157
+ member = enum(value)
158
+ except ValueError:
159
+ allowed = ", ".join(repr(member.value) for member in enum)
160
+ raise ValueError(f"{field} must be one of {allowed}, got {value!r}") from None
161
+ object.__setattr__(config, field, member) # frozen dataclass
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class TimeoutConfig:
166
+ """Timeout budget of a logical call.
167
+
168
+ ``total`` is guaranteed everywhere: the engine enforces it with a monotonic
169
+ deadline shared by all attempts, backoff sleeps and redirect hops.
170
+ Phase knobs left ``UNSET`` defer to the adapter's native defaults.
171
+ """
172
+
173
+ total: float | None = 30.0
174
+ attempt: Maybe[float | None] = UNSET
175
+ connect: Maybe[float | None] = 5.0
176
+ read: Maybe[float | None] = UNSET
177
+ write: Maybe[float | None] = UNSET
178
+ pool_acquire: Maybe[float | None] = UNSET
179
+
180
+ def __post_init__(self) -> None:
181
+ for name in ("total", "attempt", "connect", "read", "write", "pool_acquire"):
182
+ _require_positive(f"timeout.{name}", getattr(self, name))
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class PoolConfig:
187
+ """Connection pool shape; unset knobs defer to native defaults."""
188
+
189
+ max_connections: Maybe[int | None] = 100
190
+ max_keepalive: Maybe[int | None] = 20
191
+ keepalive_expiry: Maybe[float | None] = 30.0
192
+ max_connections_per_host: Maybe[int | None] = UNSET
193
+ http2: Maybe[bool] = UNSET
194
+
195
+ def __post_init__(self) -> None:
196
+ for name in ("max_connections", "max_keepalive", "keepalive_expiry", "max_connections_per_host"):
197
+ _require_positive(f"pool.{name}", getattr(self, name))
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class RetryConfig:
202
+ """Owned retry policy; the engine runs the loop, adapters only send."""
203
+
204
+ max_attempts: int = 3
205
+ initial_backoff: float = 0.1
206
+ max_backoff: float = 10.0
207
+ multiplier: float = 2.0
208
+ jitter: float = 0.2
209
+ retryable_kinds: frozenset[FailureKind] = DEFAULT_RETRYABLE_KINDS
210
+ retryable_status: frozenset[int] = DEFAULT_RETRYABLE_STATUS
211
+ methods: frozenset[str] = IDEMPOTENT_METHODS
212
+ respect_retry_after: bool = True
213
+ retry_after_max: float = 60.0
214
+ budget_ratio: float | None = 0.1
215
+ require_replayable_body: bool = True
216
+ mode: RetryMode = RetryMode.OWNED
217
+
218
+ def __post_init__(self) -> None:
219
+ _coerce_enum(self, "mode", RetryMode)
220
+ if self.max_attempts < 1:
221
+ raise ValueError(f"retry.max_attempts must be >= 1, got {self.max_attempts}")
222
+ _require_positive("retry.initial_backoff", self.initial_backoff)
223
+ _require_positive("retry.max_backoff", self.max_backoff)
224
+ if self.multiplier < 1.0:
225
+ raise ValueError(f"retry.multiplier must be >= 1, got {self.multiplier}")
226
+ if not 0.0 <= self.jitter <= 1.0:
227
+ raise ValueError(f"retry.jitter must be within [0, 1], got {self.jitter}")
228
+ if self.budget_ratio is not None and not 0.0 < self.budget_ratio <= 1.0:
229
+ raise ValueError(f"retry.budget_ratio must be within (0, 1], got {self.budget_ratio}")
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class CircuitBreakerConfig:
234
+ """Circuit breaker keyed per CircuitKey; one signal per logical call.
235
+
236
+ A ``STATUS`` outcome trips the breaker only for 5xx responses.
237
+ """
238
+
239
+ fail_threshold: int = 5
240
+ recovery_timeout: float = 60.0
241
+ half_open_max_calls: int = 1
242
+ max_keys: int = 512
243
+ key: CircuitKey = CircuitKey.ORIGIN
244
+ trip_kinds: frozenset[FailureKind] = DEFAULT_TRIP_KINDS
245
+
246
+ def __post_init__(self) -> None:
247
+ _coerce_enum(self, "key", CircuitKey)
248
+ if self.fail_threshold < 1:
249
+ raise ValueError(f"circuit_breaker.fail_threshold must be >= 1, got {self.fail_threshold}")
250
+ _require_positive("circuit_breaker.recovery_timeout", self.recovery_timeout)
251
+ if self.half_open_max_calls < 1:
252
+ raise ValueError(f"circuit_breaker.half_open_max_calls must be >= 1, got {self.half_open_max_calls}")
253
+ if self.max_keys < 1:
254
+ raise ValueError(f"circuit_breaker.max_keys must be >= 1, got {self.max_keys}")
255
+
256
+
257
+ # Module attribute captured before the dataclass: the field named ``logging``
258
+ # shadows the module inside the class body.
259
+ _INFO_LEVEL: Final = logging.INFO
260
+
261
+
262
+ @dataclass(frozen=True, slots=True)
263
+ class ObservabilityConfig:
264
+ """Which telemetry channels are active; only knobs that actually work exist here."""
265
+
266
+ logging: bool = True
267
+ metrics: bool = True
268
+ tracing: bool = True
269
+ success_log_level: int = _INFO_LEVEL
270
+ sensitive_headers: frozenset[str] = DEFAULT_SENSITIVE_HEADERS
271
+ sensitive_query_params: frozenset[str] = DEFAULT_SENSITIVE_QUERY_PARAMS
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class TlsConfig:
276
+ verify: bool = True
277
+ ca_bundle: str | None = None
278
+ cert: str | tuple[str, str] | tuple[str, str, str] | None = None
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class ProxyConfig:
283
+ """Explicit proxy or environment-driven proxies (mutually exclusive)."""
284
+
285
+ url: str | None = None
286
+ from_env: bool = False
287
+
288
+ def __post_init__(self) -> None:
289
+ if self.url is not None and self.from_env:
290
+ raise ValueError("proxy.url and proxy.from_env are mutually exclusive")
291
+
292
+
293
+ @dataclass(frozen=True, slots=True)
294
+ class NativeOptions:
295
+ """Raw passthrough to the native client, grouped by adapter-declared slots.
296
+
297
+ Validated at build time: unknown slots, reserved keys, typos and collisions
298
+ with explicitly set config fields are all errors, not silent behavior.
299
+ """
300
+
301
+ slots: Mapping[str, Mapping[str, object]] = field(default_factory=dict)
302
+
303
+ @classmethod
304
+ def of(cls, **slots: Mapping[str, object]) -> NativeOptions:
305
+ return cls(slots={name: dict(values) for name, values in slots.items()})
306
+
307
+ def for_slot(self, name: str) -> dict[str, object]:
308
+ return dict(self.slots.get(name, {}))
309
+
310
+
311
+ @dataclass(frozen=True, slots=True)
312
+ class ClientConfig:
313
+ """The whole client, described once, readable by every adapter."""
314
+
315
+ service_name: str
316
+ base_url: str | None = None
317
+ timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
318
+ pool: PoolConfig = field(default_factory=PoolConfig)
319
+ retry: RetryConfig | None = field(default_factory=RetryConfig)
320
+ circuit_breaker: CircuitBreakerConfig | None = field(default_factory=CircuitBreakerConfig)
321
+ tls: TlsConfig = field(default_factory=TlsConfig)
322
+ proxy: ProxyConfig | None = None
323
+ headers: Mapping[str, str] = field(default_factory=dict)
324
+ redirects: RedirectMode = RedirectMode.OWNED
325
+ max_redirects: int = 5
326
+ caller_override: CallerOverride = CallerOverride.CALLER_WINS
327
+ deadline_header: str | None = None
328
+ observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
329
+ native: NativeOptions = field(default_factory=NativeOptions)
330
+ on_unsupported: UnsupportedPolicy = UnsupportedPolicy.WARN
331
+
332
+ def __post_init__(self) -> None:
333
+ _coerce_enum(self, "redirects", RedirectMode)
334
+ _coerce_enum(self, "caller_override", CallerOverride)
335
+ _coerce_enum(self, "on_unsupported", UnsupportedPolicy)
336
+ if not self.service_name or not self.service_name.strip():
337
+ raise ValueError("service_name must be a non-empty string")
338
+ if self.base_url is not None and not self.base_url.startswith(("http://", "https://")):
339
+ raise ValueError(f"base_url must start with http:// or https://, got {self.base_url!r}")
340
+ if self.max_redirects < 0:
341
+ raise ValueError(f"max_redirects must be >= 0, got {self.max_redirects}")
342
+
343
+
344
+ __all__ = [
345
+ "DEFAULT_RETRYABLE_KINDS",
346
+ "DEFAULT_RETRYABLE_STATUS",
347
+ "DEFAULT_SENSITIVE_HEADERS",
348
+ "DEFAULT_SENSITIVE_QUERY_PARAMS",
349
+ "DEFAULT_TRIP_KINDS",
350
+ "UNSET",
351
+ "CallerOverride",
352
+ "CircuitBreakerConfig",
353
+ "ClientConfig",
354
+ "Maybe",
355
+ "NativeOptions",
356
+ "ObservabilityConfig",
357
+ "PoolConfig",
358
+ "ProxyConfig",
359
+ "RedirectMode",
360
+ "RetryConfig",
361
+ "RetryMode",
362
+ "TimeoutConfig",
363
+ "TlsConfig",
364
+ "UnsupportedPolicy",
365
+ "is_set",
366
+ "resolve",
367
+ ]
@@ -0,0 +1,33 @@
1
+ """The entire contract surface of the kernel: pure protocols, no SDKs."""
2
+
3
+ from .adapter import AdapterDeps as AdapterDeps
4
+ from .adapter import ClientAdapter as ClientAdapter
5
+ from .context import DeadlineSource as DeadlineSource
6
+ from .context import HeaderProvider as HeaderProvider
7
+ from .message import AsyncNormalizer as AsyncNormalizer
8
+ from .message import RequestView as RequestView
9
+ from .message import ResponseView as ResponseView
10
+ from .message import SyncNormalizer as SyncNormalizer
11
+ from .observability import ClientMetricsProtocol as ClientMetricsProtocol
12
+ from .observability import SpanProtocol as SpanProtocol
13
+ from .observability import TracerProtocol as TracerProtocol
14
+ from .settings import CircuitBreakerSettingsProtocol as CircuitBreakerSettingsProtocol
15
+ from .settings import ClientSettingsProtocol as ClientSettingsProtocol
16
+ from .settings import RetrySettingsProtocol as RetrySettingsProtocol
17
+
18
+ __all__ = [
19
+ "AdapterDeps",
20
+ "AsyncNormalizer",
21
+ "CircuitBreakerSettingsProtocol",
22
+ "ClientAdapter",
23
+ "ClientMetricsProtocol",
24
+ "ClientSettingsProtocol",
25
+ "DeadlineSource",
26
+ "HeaderProvider",
27
+ "RequestView",
28
+ "ResponseView",
29
+ "RetrySettingsProtocol",
30
+ "SpanProtocol",
31
+ "SyncNormalizer",
32
+ "TracerProtocol",
33
+ ]
@@ -0,0 +1,62 @@
1
+ """Adapter contract and the dependency bundle handed to builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
8
+
9
+ from ..capabilities import AdapterCapabilities
10
+ from ..config import ClientConfig
11
+ from .context import DeadlineSource, HeaderProvider
12
+ from .observability import ClientMetricsProtocol, TracerProtocol
13
+
14
+ if TYPE_CHECKING:
15
+ from ..plan import ClientHandle, ClientRuntime
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class AdapterDeps:
20
+ """Everything a service may inject; every field has a working default.
21
+
22
+ ``runtime`` deserves APP scope in DI: circuits, retry budgets and semaphores
23
+ must outlive REQUEST-scoped clients or they are dead code.
24
+ """
25
+
26
+ metrics: ClientMetricsProtocol | None = None
27
+ tracer: TracerProtocol | None = None
28
+ header_providers: tuple[HeaderProvider, ...] = ()
29
+ deadline_source: DeadlineSource | None = None
30
+ runtime: ClientRuntime | None = None
31
+ clock: Callable[[], float] | None = None
32
+
33
+
34
+ @runtime_checkable
35
+ class ClientAdapter(Protocol):
36
+ """One HTTP client framework, wired under its public API.
37
+
38
+ ``native_slots`` names the slots accepted in ``NativeOptions``;
39
+ ``reserved_keys`` maps slot -> keys the kit owns (with a hint how to get the
40
+ same effect legally); ``allowed_keys`` maps slot -> explicit allowlist or
41
+ ``None`` to validate against the constructor signature.
42
+ """
43
+
44
+ name: str
45
+ capabilities: AdapterCapabilities
46
+ native_slots: frozenset[str]
47
+ reserved_keys: Mapping[str, Mapping[str, str]]
48
+ allowed_keys: Mapping[str, frozenset[str] | None]
49
+
50
+ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]: ...
51
+
52
+ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]: ...
53
+
54
+
55
+ _DEFAULT_DEPS = AdapterDeps()
56
+
57
+
58
+ def default_deps() -> AdapterDeps:
59
+ return _DEFAULT_DEPS
60
+
61
+
62
+ __all__ = ["AdapterDeps", "ClientAdapter", "default_deps"]
@@ -0,0 +1,31 @@
1
+ """Ambient-context seams: header propagation and deadline budgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class HeaderProvider(Protocol):
11
+ """Supplies outgoing headers from ambient context (request-id, trace-id, ...).
12
+
13
+ Injected once per logical call, before any logging; existing header values
14
+ set by the caller are never overwritten.
15
+ """
16
+
17
+ def __call__(self) -> Mapping[str, str]: ...
18
+
19
+
20
+ @runtime_checkable
21
+ class DeadlineSource(Protocol):
22
+ """Remaining time budget of the surrounding operation, in seconds.
23
+
24
+ ``None`` means no ambient budget. The engine intersects this with the
25
+ configured total timeout and any per-call override.
26
+ """
27
+
28
+ def remaining(self) -> float | None: ...
29
+
30
+
31
+ __all__ = ["DeadlineSource", "HeaderProvider"]
@@ -0,0 +1,118 @@
1
+ """Views over native request/response objects plus the normalizer contracts.
2
+
3
+ The engine talks to these views only; it never reads ``.native``. A normalizer
4
+ is the WHOLE per-client contract: wrap, classify, freeze/rewind/discard and
5
+ stream wrapping. It comes in async and sync flavors because freeze/rewind/
6
+ discard genuinely do I/O.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, MutableMapping
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ from ..model import ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts
15
+
16
+
17
+ @runtime_checkable
18
+ class RequestView(Protocol):
19
+ """Mutable view over a native in-flight request."""
20
+
21
+ @property
22
+ def native(self) -> Any: ...
23
+
24
+ @property
25
+ def info(self) -> RequestInfo: ...
26
+
27
+ @property
28
+ def headers(self) -> MutableMapping[str, str]: ...
29
+
30
+ def caller_timeouts(self) -> ResolvedTimeouts | None:
31
+ """Per-call timeouts set by the CALLER, or None when nothing was set."""
32
+ ...
33
+
34
+ def apply_timeouts(self, timeouts: ResolvedTimeouts) -> None:
35
+ """Install the engine's per-attempt timeout plan into the native request."""
36
+ ...
37
+
38
+ def retarget(self, url: str, *, method: str | None = None, drop_body: bool = False) -> None:
39
+ """Point the request at a new URL before sending (owned redirects, balancing)."""
40
+ ...
41
+
42
+
43
+ @runtime_checkable
44
+ class ResponseView(Protocol):
45
+ """Read-only view over a native response."""
46
+
47
+ @property
48
+ def native(self) -> Any: ...
49
+
50
+ @property
51
+ def status_code(self) -> int: ...
52
+
53
+ def header(self, name: str) -> str | None: ...
54
+
55
+ @property
56
+ def location(self) -> str | None: ...
57
+
58
+
59
+ class AsyncNormalizer(Protocol):
60
+ """Async adapter contract; roughly 60-120 lines per client."""
61
+
62
+ def wrap_request(self, native: Any) -> RequestView: ...
63
+
64
+ def wrap_response(self, native: Any) -> ResponseView: ...
65
+
66
+ def classify_error(self, exc: BaseException) -> FailureKind: ...
67
+
68
+ def classify_response(self, response: ResponseView) -> Outcome:
69
+ """Default status classification lives in the engine; override only where
70
+ failures arrive NOT as exceptions."""
71
+ ...
72
+
73
+ async def freeze(self, request: RequestView) -> bool:
74
+ """Make the body replayable. False means a repeat is forbidden."""
75
+ ...
76
+
77
+ async def rewind(self, request: RequestView) -> None: ...
78
+
79
+ async def discard(self, response: ResponseView) -> None:
80
+ """MANDATORY before a repeat - otherwise the connection never returns to the pool."""
81
+ ...
82
+
83
+ def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None:
84
+ """boundary=full: wrap the body stream so read duration and errors reach
85
+ telemetry. No-op where there is nothing to wrap."""
86
+ ...
87
+
88
+ def conn_metrics(self, response: ResponseView) -> ConnMetrics | None: ...
89
+
90
+
91
+ class SyncNormalizer(Protocol):
92
+ """Sync twin of AsyncNormalizer; freeze/rewind/discard are blocking calls."""
93
+
94
+ def wrap_request(self, native: Any) -> RequestView: ...
95
+
96
+ def wrap_response(self, native: Any) -> ResponseView: ...
97
+
98
+ def classify_error(self, exc: BaseException) -> FailureKind: ...
99
+
100
+ def classify_response(self, response: ResponseView) -> Outcome: ...
101
+
102
+ def freeze(self, request: RequestView) -> bool: ...
103
+
104
+ def rewind(self, request: RequestView) -> None: ...
105
+
106
+ def discard(self, response: ResponseView) -> None: ...
107
+
108
+ def wrap_stream(self, response: ResponseView, on_done: Callable[[Outcome, float], None]) -> None: ...
109
+
110
+ def conn_metrics(self, response: ResponseView) -> ConnMetrics | None: ...
111
+
112
+
113
+ __all__ = [
114
+ "AsyncNormalizer",
115
+ "RequestView",
116
+ "ResponseView",
117
+ "SyncNormalizer",
118
+ ]