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,37 @@
1
+ """The httpx2 adapter: the shared family builder bound to the httpx2 SDK.
2
+
3
+ The class keeps the httpx adapter's name (``HttpxAdapter``) on purpose:
4
+ public names in the paired adapters are identical, so migrating a service is
5
+ one extra and one import path. The registry name is ``"httpx2"``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .._httpx_shared import FamilyAdapter
11
+ from ._imports import httpx2
12
+ from .capabilities import CAPABILITIES
13
+ from .errors import translate_call_error
14
+ from .normalize import AsyncHttpxNormalizer
15
+ from .normalize_sync import SyncHttpxNormalizer
16
+ from .transport import (
17
+ AsyncEngineTransport,
18
+ AsyncProxyRouterTransport,
19
+ SyncEngineTransport,
20
+ SyncProxyRouterTransport,
21
+ )
22
+
23
+
24
+ class HttpxAdapter(FamilyAdapter):
25
+ name = "httpx2"
26
+ capabilities = CAPABILITIES
27
+ sdk = httpx2
28
+ async_normalizer = AsyncHttpxNormalizer
29
+ sync_normalizer = SyncHttpxNormalizer
30
+ async_engine_transport = AsyncEngineTransport
31
+ sync_engine_transport = SyncEngineTransport
32
+ async_proxy_router = AsyncProxyRouterTransport
33
+ sync_proxy_router = SyncProxyRouterTransport
34
+ translate = staticmethod(translate_call_error)
35
+
36
+
37
+ __all__ = ["HttpxAdapter"]
@@ -0,0 +1,9 @@
1
+ """httpx2 capability declaration. Zero-dependency: never imports httpx2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._httpx_shared import capabilities_for
6
+
7
+ CAPABILITIES = capabilities_for("httpx2")
8
+
9
+ __all__ = ["CAPABILITIES"]
@@ -0,0 +1,14 @@
1
+ """httpx2 exception -> FailureKind classification (shared family logic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.model import FailureKind
6
+ from .._httpx_shared import classify_family_error
7
+ from ._imports import httpx2
8
+
9
+
10
+ def classify_error(exc: BaseException) -> FailureKind:
11
+ return classify_family_error(httpx2, exc)
12
+
13
+
14
+ __all__ = ["classify_error"]
@@ -0,0 +1,36 @@
1
+ """Kernel errors dual-inherited into the httpx2 family.
2
+
3
+ A user's ``except httpx2.HTTPError`` (or ``except httpx2.TimeoutException``)
4
+ keeps working when clientwright raises on its own authority. Class names
5
+ mirror the httpx adapter on purpose.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ...core.errors import CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
11
+ from .._httpx_shared import make_error_translator
12
+ from ._imports import httpx2
13
+
14
+
15
+ class HttpxCircuitOpenError(CircuitOpenError, httpx2.HTTPError):
16
+ """Circuit open, catchable as httpx2.HTTPError."""
17
+
18
+
19
+ class HttpxDeadlineExceededError(DeadlineExceededError, httpx2.TimeoutException):
20
+ """Total deadline exhausted, catchable as httpx2.TimeoutException."""
21
+
22
+
23
+ class HttpxTooManyRedirectsError(TooManyRedirectsError, httpx2.TooManyRedirects):
24
+ """Owned redirect limit exceeded, catchable as httpx2.TooManyRedirects."""
25
+
26
+
27
+ translate_call_error = make_error_translator(
28
+ HttpxCircuitOpenError, HttpxDeadlineExceededError, HttpxTooManyRedirectsError
29
+ )
30
+
31
+ __all__ = [
32
+ "HttpxCircuitOpenError",
33
+ "HttpxDeadlineExceededError",
34
+ "HttpxTooManyRedirectsError",
35
+ "translate_call_error",
36
+ ]
@@ -0,0 +1,27 @@
1
+ """Async normalizer binding: shared family logic over the httpx2 SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from .._httpx_shared import AsyncFamilyNormalizer, AsyncTimedStreamMixin
8
+ from ._imports import httpx2
9
+ from .views import HttpxRequestView
10
+
11
+
12
+ class _TimedAsyncStream(AsyncTimedStreamMixin, httpx2.AsyncByteStream):
13
+ """Times body consumption and reports read failures exactly once."""
14
+
15
+
16
+ class AsyncHttpxNormalizer(AsyncFamilyNormalizer):
17
+ def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
18
+ super().__init__(
19
+ default_timeout,
20
+ clock,
21
+ sdk=httpx2,
22
+ request_view=lambda native: HttpxRequestView(native, default_timeout),
23
+ timed_stream=_TimedAsyncStream,
24
+ )
25
+
26
+
27
+ __all__ = ["AsyncHttpxNormalizer"]
@@ -0,0 +1,27 @@
1
+ """Sync normalizer binding: same contract, blocking body operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from .._httpx_shared import SyncFamilyNormalizer, SyncTimedStreamMixin
8
+ from ._imports import httpx2
9
+ from .views import HttpxRequestView
10
+
11
+
12
+ class _TimedSyncStream(SyncTimedStreamMixin, httpx2.SyncByteStream):
13
+ """Times body consumption and reports read failures exactly once."""
14
+
15
+
16
+ class SyncHttpxNormalizer(SyncFamilyNormalizer):
17
+ def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
18
+ super().__init__(
19
+ default_timeout,
20
+ clock,
21
+ sdk=httpx2,
22
+ request_view=lambda native: HttpxRequestView(native, default_timeout),
23
+ timed_stream=_TimedSyncStream,
24
+ )
25
+
26
+
27
+ __all__ = ["SyncHttpxNormalizer"]
@@ -0,0 +1,35 @@
1
+ """Engine-driven httpx2 transports: shared mixin logic bound to httpx2 bases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._httpx_shared import (
6
+ AsyncEngineTransportMixin,
7
+ AsyncProxyRouterMixin,
8
+ SyncEngineTransportMixin,
9
+ SyncProxyRouterMixin,
10
+ )
11
+ from ._imports import httpx2
12
+
13
+
14
+ class AsyncEngineTransport(AsyncEngineTransportMixin, httpx2.AsyncBaseTransport):
15
+ pass
16
+
17
+
18
+ class SyncEngineTransport(SyncEngineTransportMixin, httpx2.BaseTransport):
19
+ pass
20
+
21
+
22
+ class AsyncProxyRouterTransport(AsyncProxyRouterMixin, httpx2.AsyncBaseTransport):
23
+ pass
24
+
25
+
26
+ class SyncProxyRouterTransport(SyncProxyRouterMixin, httpx2.BaseTransport):
27
+ pass
28
+
29
+
30
+ __all__ = [
31
+ "AsyncEngineTransport",
32
+ "AsyncProxyRouterTransport",
33
+ "SyncEngineTransport",
34
+ "SyncProxyRouterTransport",
35
+ ]
@@ -0,0 +1,45 @@
1
+ """RequestView/ResponseView bindings over httpx2 objects.
2
+
3
+ Same names as the httpx adapter on purpose: migrating a service is one extra
4
+ and one import path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncIterator, Iterator
10
+
11
+ from .._httpx_shared import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION, FamilyRequestView, FamilyResponseView
12
+ from ._imports import httpx2
13
+
14
+
15
+ class _ReplayStream(httpx2.SyncByteStream, httpx2.AsyncByteStream):
16
+ """A byte stream that can be iterated any number of times."""
17
+
18
+ def __init__(self, content: bytes) -> None:
19
+ self._content = content
20
+
21
+ def __iter__(self) -> Iterator[bytes]:
22
+ yield self._content
23
+
24
+ async def __aiter__(self) -> AsyncIterator[bytes]:
25
+ yield self._content
26
+
27
+
28
+ class HttpxRequestView(FamilyRequestView):
29
+ __slots__ = ()
30
+
31
+ def __init__(self, request: httpx2.Request, default_timeout: dict[str, float | None]) -> None:
32
+ super().__init__(request, default_timeout, sdk=httpx2, replay_stream=_ReplayStream)
33
+
34
+
35
+ class HttpxResponseView(FamilyResponseView):
36
+ __slots__ = ()
37
+
38
+
39
+ __all__ = [
40
+ "IDEMPOTENT_EXTENSION",
41
+ "ROUTE_EXTENSION",
42
+ "HttpxRequestView",
43
+ "HttpxResponseView",
44
+ "_ReplayStream",
45
+ ]
@@ -0,0 +1,26 @@
1
+ """Observability backends behind extras; the core records via protocols only.
2
+
3
+ Attribute access is lazy so this package imports cleanly with no extras
4
+ installed; touching a backend without its extra raises the backend's own
5
+ install-hint ImportError.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from .._lazy import lazy_attribute
11
+
12
+ if TYPE_CHECKING:
13
+ from ._metrics.prometheus import PrometheusClientMetrics as PrometheusClientMetrics
14
+ from ._tracing.otel import OpenTelemetryTracer as OpenTelemetryTracer
15
+
16
+ _EXPORTS = {
17
+ "OpenTelemetryTracer": "_tracing.otel",
18
+ "PrometheusClientMetrics": "_metrics.prometheus",
19
+ }
20
+
21
+
22
+ def __getattr__(name: str) -> Any:
23
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
24
+
25
+
26
+ __all__ = ["OpenTelemetryTracer", "PrometheusClientMetrics"]
@@ -0,0 +1 @@
1
+ """Metrics backends."""
@@ -0,0 +1,200 @@
1
+ """Prometheus backend for the frozen ``http_client_*`` metric families.
2
+
3
+ Instances are cached per (prefix, registry): repeated construction returns the
4
+ same object instead of raising Prometheus duplicate-registration errors - the
5
+ same guarantee the legacy ``MetricsMeta`` singleton gave.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import threading
12
+ import weakref
13
+ from typing import Any
14
+
15
+ try:
16
+ from prometheus_client import REGISTRY, Counter, Gauge, Histogram
17
+ from prometheus_client.registry import CollectorRegistry
18
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
19
+ raise ImportError("Prometheus metrics require clientwright[metrics]; install it.") from exc
20
+
21
+ from ....core.telemetry import names
22
+
23
+ _PREFIX_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
24
+
25
+ # Keyed by the registry OBJECT (weakly): id() of a dead registry can be
26
+ # recycled, which would hand back a sink bound to the wrong registry.
27
+ _INSTANCES: weakref.WeakKeyDictionary[CollectorRegistry, dict[str | None, PrometheusClientMetrics]] = (
28
+ weakref.WeakKeyDictionary()
29
+ )
30
+ _INSTANCES_LOCK = threading.Lock()
31
+
32
+
33
+ def _metric_name(name: str, prefix: str | None) -> str:
34
+ if prefix is None:
35
+ return name
36
+ if not _PREFIX_PATTERN.match(prefix):
37
+ raise ValueError(f"Invalid metric prefix {prefix!r}: must match {_PREFIX_PATTERN.pattern}")
38
+ return f"{prefix}_{name}"
39
+
40
+
41
+ class PrometheusClientMetrics:
42
+ """ClientMetricsProtocol backend over prometheus-client."""
43
+
44
+ def __new__(
45
+ cls,
46
+ prefix: str | None = None,
47
+ registry: CollectorRegistry = REGISTRY,
48
+ buckets: tuple[float, ...] = names.DEFAULT_BUCKETS,
49
+ ) -> PrometheusClientMetrics:
50
+ with _INSTANCES_LOCK:
51
+ per_registry = _INSTANCES.get(registry)
52
+ if per_registry is None:
53
+ per_registry = {}
54
+ _INSTANCES[registry] = per_registry
55
+ instance = per_registry.get(prefix)
56
+ if instance is None:
57
+ instance = super().__new__(cls)
58
+ instance._init(prefix, registry, buckets)
59
+ per_registry[prefix] = instance
60
+ return instance
61
+
62
+ def _init(self, prefix: str | None, registry: CollectorRegistry, buckets: tuple[float, ...]) -> None:
63
+ self._requests_total = Counter(
64
+ _metric_name(names.REQUESTS_TOTAL, prefix),
65
+ "Total number of outbound HTTP client calls",
66
+ names.LABELS_CALL,
67
+ registry=registry,
68
+ )
69
+ self._request_duration = Histogram(
70
+ _metric_name(names.REQUEST_DURATION, prefix),
71
+ "HTTP client call duration in seconds (boundary declared per adapter)",
72
+ names.LABELS_DURATION,
73
+ registry=registry,
74
+ buckets=list(buckets),
75
+ )
76
+ self._body_duration = Histogram(
77
+ _metric_name(names.BODY_DURATION, prefix),
78
+ "HTTP client response body read duration in seconds",
79
+ names.LABELS_DURATION,
80
+ registry=registry,
81
+ buckets=list(buckets),
82
+ )
83
+ self._attempts_total = Counter(
84
+ _metric_name(names.ATTEMPTS_TOTAL, prefix),
85
+ "Total number of physical attempts",
86
+ names.LABELS_ATTEMPT,
87
+ registry=registry,
88
+ )
89
+ self._attempt_duration = Histogram(
90
+ _metric_name(names.ATTEMPT_DURATION, prefix),
91
+ "Physical attempt duration in seconds",
92
+ names.LABELS_ATTEMPT,
93
+ registry=registry,
94
+ buckets=list(buckets),
95
+ )
96
+ self._inflight = Gauge(
97
+ _metric_name(names.INFLIGHT, prefix),
98
+ "Number of in-flight HTTP client calls",
99
+ names.LABELS_INFLIGHT,
100
+ registry=registry,
101
+ )
102
+ self._circuit_state = Gauge(
103
+ _metric_name(names.CIRCUIT_STATE, prefix),
104
+ "Circuit state per key: 0 closed, 1 half-open, 2 open",
105
+ names.LABELS_CIRCUIT,
106
+ registry=registry,
107
+ )
108
+ self._redirect_hops = Counter(
109
+ _metric_name(names.REDIRECT_HOPS_TOTAL, prefix),
110
+ "Redirect hops followed by the engine",
111
+ names.LABELS_SEAM,
112
+ registry=registry,
113
+ )
114
+ self._retry_skipped = Counter(
115
+ _metric_name(names.RETRY_SKIPPED_TOTAL, prefix),
116
+ "Retries the policy wanted but could not perform",
117
+ names.LABELS_RETRY_SKIPPED,
118
+ registry=registry,
119
+ )
120
+ self._uninstrumented = Counter(
121
+ _metric_name(names.UNINSTRUMENTED_CALLS_TOTAL, prefix),
122
+ "Calls that bypassed the instrumentation seam",
123
+ names.LABELS_SEAM,
124
+ registry=registry,
125
+ )
126
+
127
+ _STATE_VALUES = {"closed": 0, "half_open": 1, "open": 2}
128
+
129
+ def record_call(
130
+ self,
131
+ *,
132
+ service: str,
133
+ adapter: str,
134
+ seam: str,
135
+ method: str,
136
+ origin: str,
137
+ route: str,
138
+ status: str,
139
+ outcome: str,
140
+ duration: float,
141
+ ) -> None:
142
+ self._requests_total.labels(service, adapter, seam, method, origin, route, status, outcome).inc()
143
+ self._request_duration.labels(service, adapter, seam, method, origin, route).observe(duration)
144
+
145
+ def record_body_duration(
146
+ self,
147
+ *,
148
+ service: str,
149
+ adapter: str,
150
+ seam: str,
151
+ method: str,
152
+ origin: str,
153
+ route: str,
154
+ duration: float,
155
+ ) -> None:
156
+ self._body_duration.labels(service, adapter, seam, method, origin, route).observe(duration)
157
+
158
+ def record_attempt(
159
+ self,
160
+ *,
161
+ service: str,
162
+ adapter: str,
163
+ seam: str,
164
+ method: str,
165
+ origin: str,
166
+ outcome: str,
167
+ duration: float,
168
+ ) -> None:
169
+ self._attempts_total.labels(service, adapter, seam, method, origin, outcome).inc()
170
+ self._attempt_duration.labels(service, adapter, seam, method, origin, outcome).observe(duration)
171
+
172
+ def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
173
+ gauge = self._inflight.labels(service, adapter, seam, origin)
174
+ if delta > 0:
175
+ gauge.inc(float(delta))
176
+ elif delta < 0:
177
+ gauge.dec(float(abs(delta)))
178
+
179
+ def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
180
+ self._circuit_state.labels(service, adapter, key).set(self._STATE_VALUES.get(state, 0))
181
+
182
+ def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
183
+ self._redirect_hops.labels(service, adapter, seam).inc()
184
+
185
+ def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
186
+ self._retry_skipped.labels(service, adapter, seam, reason).inc()
187
+
188
+ def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
189
+ self._uninstrumented.labels(service, adapter, seam).inc()
190
+
191
+
192
+ def get_client_metrics(
193
+ prefix: str | None = None,
194
+ registry: Any = REGISTRY,
195
+ buckets: tuple[float, ...] | None = None,
196
+ ) -> PrometheusClientMetrics:
197
+ return PrometheusClientMetrics(prefix, registry, buckets or names.DEFAULT_BUCKETS)
198
+
199
+
200
+ __all__ = ["PrometheusClientMetrics", "get_client_metrics"]
@@ -0,0 +1,3 @@
1
+ """Tracing backends behind the ``[tracing]`` extra."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,61 @@
1
+ """OpenTelemetry backend for the SDK-free tracer protocol.
2
+
3
+ The client span is attached to the ambient context for its lifetime, so
4
+ ``inject_context`` propagates THIS span to the server (not merely the parent),
5
+ and any spans the server links to point at the client call. Attach and detach
6
+ happen in the same task: the engine opens the span and ends it in a finally
7
+ block without crossing task boundaries.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping, MutableMapping
13
+
14
+ try:
15
+ from opentelemetry import context as otel_context
16
+ from opentelemetry import propagate, trace
17
+ from opentelemetry.trace import SpanKind, Status, StatusCode, set_span_in_context
18
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
19
+ raise ImportError("OpenTelemetry tracing requires clientwright[tracing]; install it.") from exc
20
+
21
+
22
+ class OpenTelemetrySpan:
23
+ """SpanProtocol backend over an OTel span attached to the current context."""
24
+
25
+ __slots__ = ("_span", "_token")
26
+
27
+ def __init__(self, span: trace.Span, token: object) -> None:
28
+ self._span = span
29
+ self._token = token
30
+
31
+ def set_attribute(self, key: str, value: str | int | float | bool) -> None:
32
+ self._span.set_attribute(key, value)
33
+
34
+ def record_failure(self, description: str) -> None:
35
+ self._span.set_status(Status(StatusCode.ERROR, description))
36
+
37
+ def end(self) -> None:
38
+ try:
39
+ otel_context.detach(self._token) # type: ignore[arg-type]
40
+ finally:
41
+ self._span.end()
42
+
43
+
44
+ class OpenTelemetryTracer:
45
+ """TracerProtocol backend over opentelemetry-api."""
46
+
47
+ __slots__ = ("_tracer",)
48
+
49
+ def __init__(self, tracer_provider: trace.TracerProvider | None = None) -> None:
50
+ self._tracer = trace.get_tracer("clientwright", tracer_provider=tracer_provider)
51
+
52
+ def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> OpenTelemetrySpan:
53
+ span = self._tracer.start_span(name, kind=SpanKind.CLIENT, attributes=dict(attributes))
54
+ token = otel_context.attach(set_span_in_context(span))
55
+ return OpenTelemetrySpan(span, token)
56
+
57
+ def inject_context(self, headers: MutableMapping[str, str]) -> None:
58
+ propagate.inject(headers)
59
+
60
+
61
+ __all__ = ["OpenTelemetrySpan", "OpenTelemetryTracer"]
@@ -0,0 +1,45 @@
1
+ """requests adapter (``[requests]`` extra): real Session, engine underneath.
2
+
3
+ SDK-backed names resolve lazily (see ``clientwright.adapters._lazy``): importing
4
+ this package never imports requests, 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 RequestsAdapter as RequestsAdapter
19
+ from .capabilities import CAPABILITIES as CAPABILITIES
20
+ from .errors import RequestsCircuitOpenError as RequestsCircuitOpenError
21
+ from .errors import RequestsDeadlineExceededError as RequestsDeadlineExceededError
22
+ from .errors import RequestsTooManyRedirectsError as RequestsTooManyRedirectsError
23
+
24
+ _EXPORTS = {
25
+ "CAPABILITIES": "capabilities",
26
+ "RequestsAdapter": "adapter",
27
+ "RequestsCircuitOpenError": "errors",
28
+ "RequestsDeadlineExceededError": "errors",
29
+ "RequestsTooManyRedirectsError": "errors",
30
+ }
31
+
32
+
33
+ def __getattr__(name: str) -> Any:
34
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
35
+
36
+
37
+ __all__ = [
38
+ "CAPABILITIES",
39
+ "CallOptions",
40
+ "RequestsAdapter",
41
+ "RequestsCircuitOpenError",
42
+ "RequestsDeadlineExceededError",
43
+ "RequestsTooManyRedirectsError",
44
+ "call_options",
45
+ ]
@@ -0,0 +1,21 @@
1
+ """Lazy import guard for the optional requests stack.
2
+
3
+ Importing this module fails with a friendly message when the ``requests``
4
+ extra is not installed. Every requests submodule imports its third-party
5
+ symbols from here. urllib3 is re-exported too: requests depends on it, and the
6
+ classifier needs the underlying exception types to tell a mid-stream
7
+ disconnect from a refused connection.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ _INSTALL_HINT = "requests support requires clientwright[requests]; install it."
13
+
14
+ try:
15
+ import requests
16
+ import urllib3
17
+ from requests.adapters import HTTPAdapter
18
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
19
+ raise ImportError(_INSTALL_HINT) from exc
20
+
21
+ __all__ = ["HTTPAdapter", "requests", "urllib3"]