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,107 @@
1
+ """deadline-budget integration (``[deadline]`` extra).
2
+
3
+ The core stays zero-dependency: the engine consumes the structural
4
+ ``DeadlineSource`` protocol, and this module supplies sources backed by a
5
+ deadline-budget ``BudgetContext`` - plus the ambient channel that carries one.
6
+
7
+ deadline-budget deliberately has no implicit context: a ``BudgetContext`` is
8
+ handed from call site to call site by argument. A client engine sits far below
9
+ the code that knows the budget, so the ambient channel lives here::
10
+
11
+ from clientwright.contrib.deadline import AmbientDeadlineSource, use_budget
12
+
13
+ deps = AdapterDeps(deadline_source=AmbientDeadlineSource())
14
+ client = build("httpx", config, deps)
15
+
16
+ with use_budget(BudgetContext.create(total_seconds=5.0)):
17
+ await client.get("/users") # runs with what is left of those 5 seconds
18
+
19
+ Without the block every call behaves exactly as before: the ambient source
20
+ finds no budget and the engine falls back to the configured total.
21
+
22
+ Typed structurally on purpose - this module never imports deadline-budget, so
23
+ any object with ``remaining()``/``expired()`` fits; the ``[deadline]`` extra
24
+ exists to pin the library for services that use the real one. A ContextVar is
25
+ per-task: a task started under ``use_budget`` inherits the budget (a fan-out
26
+ shares one deadline), siblings do not.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Generator
32
+ from contextlib import contextmanager
33
+ from contextvars import ContextVar, Token
34
+ from typing import Protocol, runtime_checkable
35
+
36
+
37
+ @runtime_checkable
38
+ class DeadlineBudgetProtocol(Protocol):
39
+ """What a source needs of a request budget: the shape of ``deadline_budget.BudgetContext``."""
40
+
41
+ def remaining(self) -> float:
42
+ """Seconds left; negative once the deadline has passed."""
43
+ ...
44
+
45
+ def expired(self) -> bool: ...
46
+
47
+
48
+ _CURRENT_BUDGET: ContextVar[DeadlineBudgetProtocol | None] = ContextVar("clientwright_budget", default=None)
49
+
50
+
51
+ def current_budget() -> DeadlineBudgetProtocol | None:
52
+ """The budget installed for the current task, or None if there is none."""
53
+ return _CURRENT_BUDGET.get()
54
+
55
+
56
+ @contextmanager
57
+ def use_budget(budget: DeadlineBudgetProtocol | None) -> Generator[DeadlineBudgetProtocol | None]:
58
+ """Install ``budget`` as the current one for the duration of the block.
59
+
60
+ The previous value is restored on the way out, so nesting works and an
61
+ inner budget cannot outlive its block. Passing None detaches an inherited
62
+ budget - for background work that must not die with the request that
63
+ spawned it.
64
+ """
65
+ token: Token[DeadlineBudgetProtocol | None] = _CURRENT_BUDGET.set(budget)
66
+ try:
67
+ yield budget
68
+ finally:
69
+ _CURRENT_BUDGET.reset(token)
70
+
71
+
72
+ class BudgetDeadlineSource:
73
+ """DeadlineSource over ONE fixed budget - for a client scoped to a request."""
74
+
75
+ __slots__ = ("_budget",)
76
+
77
+ def __init__(self, budget: DeadlineBudgetProtocol) -> None:
78
+ self._budget = budget
79
+
80
+ def remaining(self) -> float | None:
81
+ return self._budget.remaining()
82
+
83
+
84
+ class AmbientDeadlineSource:
85
+ """DeadlineSource reading whatever ``use_budget`` installed in this task.
86
+
87
+ The right default for a long-lived client: each call picks up the budget
88
+ of the request being served, and calls outside any budget run unbounded
89
+ by it (only the configured total applies).
90
+ """
91
+
92
+ __slots__ = ()
93
+
94
+ def remaining(self) -> float | None:
95
+ budget = current_budget()
96
+ if budget is None:
97
+ return None
98
+ return budget.remaining()
99
+
100
+
101
+ __all__ = [
102
+ "AmbientDeadlineSource",
103
+ "BudgetDeadlineSource",
104
+ "DeadlineBudgetProtocol",
105
+ "current_budget",
106
+ "use_budget",
107
+ ]
@@ -0,0 +1,80 @@
1
+ """Dishka integration (``[dishka]`` extra): APP-scope runtime, leak-free lifecycle.
2
+
3
+ Two lessons from the legacy kit are structural here:
4
+
5
+ - ``ClientRuntime`` (circuits, retry budgets, per-origin limiters) is APP
6
+ scope. A REQUEST-scoped runtime resets breaker state on every request and
7
+ turns the circuit breaker into decoration.
8
+ - The client is provided by a GENERATOR provide whose finally closes it - the
9
+ legacy provider parked ``aclose`` on an AsyncExitStack that nothing ever
10
+ closed, leaking a client per request.
11
+
12
+ Usage::
13
+
14
+ container = make_async_container(
15
+ ClientwrightProvider("httpx", config),
16
+ ...,
17
+ )
18
+ handle = await container.get(ClientHandle)
19
+ client: httpx.AsyncClient = handle.client
20
+
21
+ Known limitation: the ``http_client_circuit_state`` gauge is wired when the
22
+ ADAPTER builds the runtime; a runtime built here (to be shared) has no
23
+ telemetry listener, so state-change events are not exported as a gauge.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import AsyncIterator
29
+ from dataclasses import replace
30
+ from typing import Any
31
+
32
+ try:
33
+ from dishka import Provider, Scope, provide
34
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
35
+ raise ImportError("Dishka integration requires clientwright[dishka]; install it.") from exc
36
+
37
+ from ..core.config import ClientConfig
38
+ from ..core.contracts.adapter import AdapterDeps, default_deps
39
+ from ..core.plan import ClientHandle, ClientRuntime
40
+ from ..core.registry import resolve_adapter
41
+
42
+
43
+ class ClientwrightProvider(Provider):
44
+ """One async native client with an APP-scope runtime and guaranteed close.
45
+
46
+ For several upstreams, instantiate one provider per upstream in separate
47
+ containers, or subclass and add typed aliases (e.g. a provider returning
48
+ ``httpx.AsyncClient`` from the handle) for ergonomic injection.
49
+ """
50
+
51
+ scope = Scope.APP
52
+
53
+ def __init__(self, adapter: str, config: ClientConfig, deps: AdapterDeps | None = None) -> None:
54
+ super().__init__()
55
+ self._adapter = adapter
56
+ self._config = config
57
+ self._deps = deps or default_deps()
58
+
59
+ @provide
60
+ def client_runtime(self) -> ClientRuntime:
61
+ if self._deps.runtime is not None:
62
+ return self._deps.runtime
63
+ return ClientRuntime.for_config(self._config, clock=self._deps.clock)
64
+
65
+ @provide
66
+ async def client_handle(self, runtime: ClientRuntime) -> AsyncIterator[ClientHandle[Any]]:
67
+ adapter = resolve_adapter(self._adapter)()
68
+ handle: ClientHandle[Any] = adapter.build_async(self._config, replace(self._deps, runtime=runtime))
69
+ try:
70
+ yield handle
71
+ finally:
72
+ # The whole point: close travels WITH the provide, not on a stack
73
+ # nobody closes.
74
+ if handle.aclose is not None:
75
+ await handle.aclose()
76
+ elif handle.close is not None:
77
+ handle.close()
78
+
79
+
80
+ __all__ = ["ClientwrightProvider"]
@@ -0,0 +1,6 @@
1
+ """Pure kernel of clientwright.
2
+
3
+ Everything under ``clientwright.core`` depends on the standard library only.
4
+ Deleting ``clientwright/adapters`` must leave this package importable and its
5
+ tests green; CI enforces the rule with import-linter contracts.
6
+ """
@@ -0,0 +1 @@
1
+ """Client-side balancing seam. v1 ships the seam only, no implementation."""
@@ -0,0 +1,23 @@
1
+ """Target resolution seam for future client-side load balancing.
2
+
3
+ Deliberately empty of implementations: in k8s/service-mesh environments client
4
+ LB is usually harmful. The seam costs nothing because ``RequestView.retarget``
5
+ already exists for owned redirects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from ..model import Attempt, RequestInfo
14
+
15
+
16
+ @runtime_checkable
17
+ class TargetResolverProtocol(Protocol):
18
+ """Pure target choice; no health RPCs, no I/O."""
19
+
20
+ def pick(self, info: RequestInfo, history: Sequence[Attempt]) -> str: ...
21
+
22
+
23
+ __all__ = ["TargetResolverProtocol"]
@@ -0,0 +1,156 @@
1
+ """Machine-readable declarations of what each adapter can and cannot do.
2
+
3
+ Uniformity is sold by policy and telemetry schema, not by pretending semantics
4
+ match: every divergence is declared here, validated at build time, and visible
5
+ in the ``ConfigApplicationReport`` instead of a README paragraph.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+
15
+ from .config import UnsupportedPolicy
16
+ from .errors import UnsupportedCapabilityError
17
+ from .model import FailureKind
18
+
19
+ logger = logging.getLogger("clientwright.capabilities")
20
+
21
+
22
+ class Support(StrEnum):
23
+ NATIVE = "native"
24
+ EMULATED = "emulated"
25
+ DEGRADED = "degraded"
26
+ ABSENT = "absent"
27
+
28
+
29
+ class Capability(StrEnum):
30
+ TIMEOUT_TOTAL = "timeout_total"
31
+ TIMEOUT_ATTEMPT = "timeout_attempt"
32
+ TIMEOUT_CONNECT = "timeout_connect"
33
+ TIMEOUT_READ = "timeout_read"
34
+ TIMEOUT_WRITE = "timeout_write"
35
+ TIMEOUT_POOL = "timeout_pool"
36
+ DEADLINE_HARD = "deadline_hard"
37
+ POOL_LIMIT_TOTAL = "pool_limit_total"
38
+ POOL_LIMIT_PER_HOST = "pool_limit_per_host"
39
+ KEEPALIVE = "keepalive"
40
+ POOL_METRICS = "pool_metrics"
41
+ CONN_METRICS = "conn_metrics"
42
+ REDIRECTS_OWNABLE = "redirects_ownable"
43
+ NATIVE_RETRY_DISABLEABLE = "native_retry_disableable"
44
+ PER_CALL_OPTIONS = "per_call_options"
45
+ RETROFIT = "retrofit"
46
+ EXACT_NATIVE_TYPE = "exact_native_type"
47
+ BALANCER = "balancer"
48
+ HTTP2 = "http2"
49
+ HTTP3 = "http3"
50
+ PROXY = "proxy"
51
+
52
+
53
+ class DurationBoundary(StrEnum):
54
+ HEADERS = "headers"
55
+ FULL = "full"
56
+
57
+
58
+ class SeamGranularity(StrEnum):
59
+ HOP = "hop"
60
+ LOGICAL = "logical"
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class AdapterCapabilities:
65
+ """Everything an adapter admits about itself, in one frozen record."""
66
+
67
+ adapter: str
68
+ seam: str
69
+ granularity: SeamGranularity
70
+ boundary: DurationBoundary
71
+ support: Mapping[Capability, Support]
72
+ emits: frozenset[FailureKind]
73
+ collapses: Mapping[FailureKind, FailureKind] = field(default_factory=dict)
74
+ notes: Mapping[str, str] = field(default_factory=dict)
75
+
76
+ def support_of(self, capability: Capability) -> Support:
77
+ return self.support.get(capability, Support.ABSENT)
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class ConfigApplicationReport:
82
+ """What actually happened when a config met an adapter."""
83
+
84
+ adapter: str
85
+ applied_natively: frozenset[Capability] = frozenset()
86
+ emulated: frozenset[Capability] = frozenset()
87
+ dropped: Mapping[Capability, str] = field(default_factory=dict)
88
+ dead_retryable_kinds: frozenset[FailureKind] = frozenset()
89
+ collapsed_kinds: Mapping[FailureKind, FailureKind] = field(default_factory=dict)
90
+ native_overrides: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
91
+
92
+ @property
93
+ def has_issues(self) -> bool:
94
+ return bool(self.dropped or self.dead_retryable_kinds)
95
+
96
+ def issues(self) -> list[str]:
97
+ problems = [f"{capability.value}: {reason}" for capability, reason in self.dropped.items()]
98
+ if self.dead_retryable_kinds:
99
+ dead = ", ".join(sorted(kind.value for kind in self.dead_retryable_kinds))
100
+ problems.append(f"retryable_kinds never emitted by {self.adapter}: {dead}")
101
+ return problems
102
+
103
+ def enforce(self, policy: UnsupportedPolicy) -> None:
104
+ """Apply the on_unsupported policy: raise, warn or stay silent."""
105
+ if not self.has_issues or policy is UnsupportedPolicy.IGNORE:
106
+ return
107
+ problems = self.issues()
108
+ if policy is UnsupportedPolicy.STRICT:
109
+ raise UnsupportedCapabilityError(
110
+ f"Adapter {self.adapter!r} cannot express the requested config: " + "; ".join(problems)
111
+ )
112
+ for problem in problems:
113
+ logger.warning("Adapter %s: %s", self.adapter, problem)
114
+
115
+
116
+ def dead_retryable_kinds(
117
+ requested: frozenset[FailureKind], capabilities: AdapterCapabilities
118
+ ) -> frozenset[FailureKind]:
119
+ """Kinds the retry policy waits for but the adapter can never produce.
120
+
121
+ Collapsed kinds count as reachable through their coarser target.
122
+ """
123
+ reachable = set(capabilities.emits)
124
+ for source, target in capabilities.collapses.items():
125
+ if target in reachable:
126
+ reachable.add(source)
127
+ return frozenset(requested - reachable)
128
+
129
+
130
+ def capabilities_matrix() -> dict[str, AdapterCapabilities]:
131
+ """Capability records of every registered adapter.
132
+
133
+ Adapter capability modules are zero-dependency and are imported dynamically
134
+ by registry path, so the matrix builds in an environment without a single
135
+ extra installed (and the core->adapters import-linter contract holds:
136
+ there are no static imports).
137
+ """
138
+ return _capability_records()
139
+
140
+
141
+ def _capability_records() -> dict[str, AdapterCapabilities]:
142
+ from .registry import capability_records # noqa: PLC0415 - deferred to keep module init order acyclic
143
+
144
+ return capability_records()
145
+
146
+
147
+ __all__ = [
148
+ "AdapterCapabilities",
149
+ "Capability",
150
+ "ConfigApplicationReport",
151
+ "DurationBoundary",
152
+ "SeamGranularity",
153
+ "Support",
154
+ "capabilities_matrix",
155
+ "dead_retryable_kinds",
156
+ ]