diff --git a/pyproject.toml b/pyproject.toml index 08859dbc..bce4ee5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dev = [ "chardet==6.0.0.post1", "coverage[toml]==7.10.6", "cryptography==46.0.7", + "inline-snapshot>=0.34.2", + "logfire>=4.37.0", "pytest>=9.0.3", "pytest-codspeed>=4.1.1", "pytest-httpbin==2.0.0", diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 8d810bb6..1978a025 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -28,6 +28,7 @@ request_context, ) from ._models import Cookies, Headers, Request, Response +from ._opentelemetry import trace_request from ._sse import EventSource from ._status_codes import codes from ._transports.base import AsyncBaseTransport, BaseTransport @@ -1015,7 +1016,9 @@ def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send an async request with a sync Client instance.") with request_context(request=request): - response = transport.handle_request(request) + with trace_request(request) as trace: + response = transport.handle_request(request) + trace.set_response(response) assert isinstance(response.stream, SyncByteStream) @@ -1761,7 +1764,9 @@ async def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send a sync request with an AsyncClient instance.") with request_context(request=request): - response = await transport.handle_async_request(request) + with trace_request(request) as trace: + response = await transport.handle_async_request(request) + trace.set_response(response) assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py new file mode 100644 index 00000000..175d34c2 --- /dev/null +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import os +import re +import time +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import AbstractContextManager, contextmanager +from functools import cache +from types import TracebackType +from typing import TYPE_CHECKING, NamedTuple, Protocol, TypeAlias + +from .__version__ import __version__ +from ._models import Headers, Request, Response + +if TYPE_CHECKING: + from opentelemetry.metrics import Histogram, Meter, MeterProvider + from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, TracerProvider + +AttributeValue: TypeAlias = str | bool | int | float | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float] +Attributes: TypeAlias = dict[str, AttributeValue] + + +class PropagateModule(Protocol): + def inject(self, carrier: Headers) -> None: ... + + +class MetricsModule(Protocol): + def get_meter( + self, + name: str, + version: str = "", + meter_provider: MeterProvider | None = None, + schema_url: str | None = None, + attributes: Mapping[str, AttributeValue] | None = None, + ) -> Meter: ... + + +class TraceModule(Protocol): + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + tracer_provider: TracerProvider | None = None, + schema_url: str | None = None, + attributes: Mapping[str, AttributeValue] | None = None, + ) -> Tracer: ... + + +class OpenTelemetryDependencies(NamedTuple): + propagate: PropagateModule + span_kind: type[SpanKind] + status: type[Status] + status_code: type[StatusCode] + is_http_instrumentation_enabled: Callable[[], bool] + tracer: Tracer + duration_histogram: Histogram + + +KNOWN_HTTP_METHODS = { + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "QUERY", + "TRACE", +} + +SEMCONV_SCHEMA_URL = "https://opentelemetry.io/schemas/1.42.0" +INSTRUMENTATION_NAME = "httpx2" +CLIENT_REQUEST_DURATION = "http.client.request.duration" +SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie"} +HTTP_CLIENT_REQUEST_DURATION_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10, +) + + +@contextmanager +def trace_request(request: Request) -> Iterator[RequestTrace | NoOpRequestTrace]: + dependencies = opentelemetry_dependencies() + if dependencies is None or not is_enabled(request, dependencies): + yield NOOP_REQUEST_TRACE + else: + with record_request(request, dependencies) as trace: + yield trace + + +@cache +def opentelemetry_dependencies() -> OpenTelemetryDependencies | None: + try: + from opentelemetry import metrics, propagate, trace + from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled + from opentelemetry.trace import SpanKind, Status, StatusCode + except ImportError: # pragma: no cover + return None + + tracer = get_tracer(trace) + meter = get_meter(metrics) + return OpenTelemetryDependencies( + propagate=propagate, + span_kind=SpanKind, + status=Status, + status_code=StatusCode, + is_http_instrumentation_enabled=is_http_instrumentation_enabled, + tracer=tracer, + duration_histogram=create_duration_histogram(meter), + ) + + +def is_enabled(request: Request, dependencies: OpenTelemetryDependencies) -> bool: + return is_http_url(request) and dependencies.is_http_instrumentation_enabled() + + +def start_request(request: Request, dependencies: OpenTelemetryDependencies) -> RequestTrace: + span_attributes = request_attributes(request) + metric_attributes = request_metric_attributes(request) + name = span_name(request.method) + span_cm = dependencies.tracer.start_as_current_span( + name, + kind=dependencies.span_kind.CLIENT, + attributes=span_attributes, + end_on_exit=False, + ) + span = span_cm.__enter__() + trace = RequestTrace( + span=span, + duration_histogram=dependencies.duration_histogram, + metric_attributes=metric_attributes, + status=dependencies.status, + status_code=dependencies.status_code, + span_context_manager=span_cm, + start=time.perf_counter(), + ) + try: + dependencies.propagate.inject(request.headers) + except Exception as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + trace.close() + raise + return trace + + +@contextmanager +def record_request(request: Request, dependencies: OpenTelemetryDependencies) -> Iterator[RequestTrace]: + trace = start_request(request, dependencies) + try: + yield trace + except Exception as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + raise + finally: + trace.close() + + +class RequestTrace: + def __init__( + self, + *, + span: Span, + duration_histogram: Histogram, + metric_attributes: Attributes, + status: type[Status], + status_code: type[StatusCode], + span_context_manager: AbstractContextManager[Span], + start: float, + ) -> None: + self._span = span + self._duration_histogram = duration_histogram + self._metric_attributes = metric_attributes + self._status = status + self._status_code = status_code + self._span_context_manager = span_context_manager + self._start = start + self._closed = False + self._detached = False + + def set_response(self, response: Response) -> None: + attributes = response_attributes(response) + self._metric_attributes.update(response_metric_attributes(response)) + set_attributes(self._span, attributes) + + if is_error_status(response.status_code): + self.set_error(str(response.status_code)) + + def set_exception(self, exc: Exception) -> None: + if self._span.is_recording(): + self._span.record_exception(exc) + self.set_error(f"{type(exc).__module__}.{type(exc).__qualname__}") + + def record_duration(self, duration: float) -> None: + self._duration_histogram.record(max(duration, 0), attributes=self._metric_attributes) + + def close(self) -> None: + if self._closed: # pragma: no cover + return + + self._closed = True + self.detach_current() + self.record_duration(time.perf_counter() - self._start) + self._span.end() + + def detach_current( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + if self._detached: + return + + self._detached = True + self._span_context_manager.__exit__(exc_type, exc_value, traceback) + + def set_error(self, error_type: str) -> None: + self._metric_attributes["error.type"] = error_type + if self._span.is_recording(): + self._span.set_attribute("error.type", error_type) + self._span.set_status(self._status(self._status_code.ERROR)) + + +class NoOpRequestTrace: + def set_response(self, response: Response) -> None: + pass + + +NOOP_REQUEST_TRACE = NoOpRequestTrace() + + +def request_attributes(request: Request) -> Attributes: + attributes = request_metric_attributes(request) + attributes["url.full"] = redact_url(request) + + header_patterns = captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST") + if header_patterns: + attributes.update(header_attributes("http.request.header", request.headers, header_patterns)) + + return attributes + + +def request_metric_attributes(request: Request) -> Attributes: + method = known_method(request.method) + attributes: Attributes = { + "http.request.method": method, + } + + if method == "_OTHER": + attributes["http.request.method_original"] = request.method + + if request.url.host: + attributes["server.address"] = request.url.host + + port = request.url.port or {"http": 80, "https": 443}.get(request.url.scheme) + if port is not None: + attributes["server.port"] = port + + return attributes + + +def response_attributes(response: Response) -> Attributes: + attributes = response_metric_attributes(response) + + header_patterns = captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE") + if header_patterns: + attributes.update(header_attributes("http.response.header", response.headers, header_patterns)) + + return attributes + + +def response_metric_attributes(response: Response) -> Attributes: + attributes: Attributes = {"http.response.status_code": response.status_code} + + if response.http_version: + attributes["network.protocol.version"] = response.http_version.removeprefix("HTTP/") + + return attributes + + +def is_http_url(request: Request) -> bool: + return request.url.scheme in {"http", "https"} + + +def span_name(method: str) -> str: + method = known_method(method) + return "HTTP" if method == "_OTHER" else method + + +def known_method(method: str) -> str: + return method if method in known_methods() else "_OTHER" + + +def known_methods() -> set[str]: + configured_methods = os.environ.get("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS") + if configured_methods is None: + return KNOWN_HTTP_METHODS + + return {method.strip().upper() for method in configured_methods.split(",") if method.strip()} + + +def redact_url(request: Request) -> str: + if request.url.userinfo: + return str(request.url.copy_with(username="REDACTED", password="REDACTED")) + return str(request.url) + + +def is_error_status(status_code: int) -> bool: + return status_code >= 400 + + +def set_attributes(span: Span, attributes: Attributes) -> None: + if not span.is_recording(): + return + + for name, value in attributes.items(): + span.set_attribute(name, value) + + +def captured_headers(name: str) -> list[re.Pattern[str]]: + value = os.environ.get(name, "") + return [re.compile(pattern.strip(), re.IGNORECASE) for pattern in value.split(",") if pattern.strip()] + + +def header_attributes( + prefix: str, + headers: Headers, + captured_headers: list[re.Pattern[str]], +) -> Attributes: + sensitive_headers = sensitive_header_patterns() + attributes: Attributes = {} + for key in headers.keys(): + if not any(pattern.fullmatch(key) for pattern in captured_headers): + continue + attribute = f"{prefix}.{key.lower().replace('-', '_')}" + values = headers.get_list(key, split_commas=True) + attributes[attribute] = tuple( + "[REDACTED]" if is_sensitive_header(key, sensitive_headers) else value for value in values + ) + return attributes + + +def sensitive_header_patterns() -> list[re.Pattern[str]]: + value = os.environ.get("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", "") + names = [*SENSITIVE_HEADERS, *[item.strip() for item in value.split(",") if item.strip()]] + return [re.compile(name, re.IGNORECASE) for name in names] + + +def is_sensitive_header(key: str, sensitive_headers: list[re.Pattern[str]]) -> bool: + return any(pattern.fullmatch(key) for pattern in sensitive_headers) + + +def get_tracer(trace: TraceModule) -> Tracer: + try: + return trace.get_tracer( + INSTRUMENTATION_NAME, + instrumenting_library_version=__version__, + schema_url=SEMCONV_SCHEMA_URL, + ) + except TypeError: # pragma: no cover + return trace.get_tracer(INSTRUMENTATION_NAME, __version__) + + +def get_meter(metrics: MetricsModule) -> Meter: + try: + return metrics.get_meter( + INSTRUMENTATION_NAME, + version=__version__, + schema_url=SEMCONV_SCHEMA_URL, + ) + except TypeError: # pragma: no cover + return metrics.get_meter(INSTRUMENTATION_NAME, __version__) + + +def create_duration_histogram(meter: Meter) -> Histogram: + try: + return meter.create_histogram( + name=CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + explicit_bucket_boundaries_advisory=HTTP_CLIENT_REQUEST_DURATION_BUCKETS, + ) + except TypeError: # pragma: no cover + return meter.create_histogram( + name=CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index dc194f7f..180b582d 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -64,6 +64,10 @@ cli = [ http2 = [ "h2>=3,<5", ] +opentelemetry = [ + "opentelemetry-api>=1.42.0", + "opentelemetry-instrumentation>=0.63b0", +] socks = [ "socksio==1.*", ] diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py new file mode 100644 index 00000000..35a169e4 --- /dev/null +++ b/tests/httpx2/test_opentelemetry.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from typing import Any, cast + +import logfire +import pytest +from inline_snapshot import snapshot +from logfire.testing import CaptureLogfire + +import httpx2 +import httpx2._opentelemetry as otel_module + + +@pytest.fixture(autouse=True) +def clear_opentelemetry_cache() -> Iterator[None]: + otel_module.opentelemetry_dependencies.cache_clear() + yield + otel_module.opentelemetry_dependencies.cache_clear() + + +def test_sync_client_emits_current_http_client_span_and_duration_metric(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["traceparent"].startswith("00-") + return httpx2.Response( + 404, + headers={"content-type": "text/plain"}, + extensions={"http_version": b"HTTP/2"}, + ) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + response = client.get("https://user:password@example.com:8443/example") + + assert response.status_code == 404 + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 8443, + "url.full": "https://REDACTED:REDACTED@example.com:8443/example", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 404, + "network.protocol.version": "2", + "error.type": "404", + "logfire.level_num": 17, + }, + } + ] + ) + + metric = _duration_metric(capfire) + assert metric["name"] == "http.client.request.duration" + [data_point] = metric["data"]["data_points"] + assert data_point["count"] == 1 + assert data_point["sum"] >= 0 + assert data_point["attributes"] == { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 8443, + "http.response.status_code": 404, + "network.protocol.version": "2", + "error.type": "404", + } + + +@pytest.mark.anyio +async def test_async_client_emits_opentelemetry(capfire: CaptureLogfire) -> None: + async def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["traceparent"].startswith("00-") + return httpx2.Response(204) + + transport = httpx2.MockTransport(handler) + async with httpx2.AsyncClient(transport=transport) as client: + response = await client.get("https://example.com/") + + assert response.status_code == 204 + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 204, + "network.protocol.version": "1.1", + }, + } + ] + ) + assert _duration_metric(capfire)["data"]["data_points"] + + +def test_opentelemetry_honors_logfire_suppression_context(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert "traceparent" not in request.headers + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with logfire.suppress_instrumentation(): + with httpx2.Client(transport=transport) as client: + response = client.get("https://example.com/") + + assert response.status_code == 200 + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) + assert _duration_metrics(capfire) == [] + + +def test_opentelemetry_records_exceptions(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + raise httpx2.ConnectError("no route") + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(httpx2.ConnectError): + client.get("https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 4000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "error.type": "httpx2.ConnectError", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "httpx2.ConnectError", + "exception.message": "no route", + "exception.stacktrace": "httpx2.ConnectError: no route", + "exception.escaped": "False", + }, + }, + { + "name": "exception", + "timestamp": 3000000000, + "attributes": { + "exception.type": "httpx2.ConnectError", + "exception.message": "no route", + "exception.stacktrace": "httpx2.ConnectError: no route", + "exception.escaped": "False", + }, + }, + ], + } + ] + ) + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" + + +def test_opentelemetry_does_not_record_cancelled_error_as_request_error(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + raise asyncio.CancelledError + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(asyncio.CancelledError): + client.get("https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + }, + } + ] + ) + assert "error.type" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + + +def test_opentelemetry_records_propagation_injection_exceptions( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + dependencies = otel_module.opentelemetry_dependencies() + assert dependencies is not None + + def inject(headers: httpx2.Headers) -> None: + raise RuntimeError("inject failed") + + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be called") # pragma: no cover + + monkeypatch.setattr(dependencies.propagate, "inject", inject) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(RuntimeError, match="inject failed"): + client.get("https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 4000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "error.type": "builtins.RuntimeError", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "inject failed", + "exception.stacktrace": "RuntimeError: inject failed", + "exception.escaped": "False", + }, + }, + { + "name": "exception", + "timestamp": 3000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "inject failed", + "exception.stacktrace": "RuntimeError: inject failed", + "exception.escaped": "False", + }, + }, + ], + } + ] + ) + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" + + +def test_opentelemetry_honors_configured_known_methods( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS", "GET,PROPFIND") + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + client.request("PROPFIND", "https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "PROPFIND", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "PROPFIND", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "PROPFIND", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + }, + } + ] + ) + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["http.request.method"] == "PROPFIND" + + +def test_opentelemetry_uses_other_for_unknown_methods(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + client.request("BREW", "https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "HTTP", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "_OTHER", + "http.request.method_original": "BREW", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "HTTP", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + }, + } + ] + ) + metric_attributes = _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + assert metric_attributes["http.request.method"] == "_OTHER" + assert metric_attributes["http.request.method_original"] == "BREW" + + +def test_opentelemetry_captures_and_sanitizes_opt_in_headers( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST", "x-private,x-.*") + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE", "x-response-private") + monkeypatch.setenv( + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", + "x-private,x-response-private", + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, headers={"x-response-private": "secret"}) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport, headers={"x-private": "secret", "x-request-id": "abc"}) as client: + client.get("https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "http.request.header.x_private": ("[REDACTED]",), + "http.request.header.x_request_id": ("abc",), + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + "http.response.header.x_response_private": ("[REDACTED]",), + }, + } + ] + ) + assert "http.request.header.x_private" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + + +def _duration_metric(capfire: CaptureLogfire) -> dict[str, Any]: + [metric] = _duration_metrics(capfire) + return metric + + +def _duration_metrics(capfire: CaptureLogfire) -> list[dict[str, Any]]: + try: + metrics = cast(Any, capfire).get_collected_metrics() + except AttributeError: + return [] + return [metric for metric in metrics if metric["name"] == "http.client.request.duration"] diff --git a/uv.lock b/uv.lock index ef708e99..d1ff7c10 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,8 @@ dev = [ { name = "cryptography", specifier = "==46.0.7" }, { name = "httpcore2", extras = ["asyncio", "trio", "http2", "socks"], editable = "src/httpcore2" }, { name = "httpx2", extras = ["brotli", "cli", "http2", "socks", "zstd"], editable = "src/httpx2" }, + { name = "inline-snapshot", specifier = ">=0.34.2" }, + { name = "logfire", specifier = ">=4.37.0" }, { name = "mypy", specifier = "==1.17.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-codspeed", specifier = ">=4.1.1" }, @@ -209,6 +211,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "async-generator" version = "1.10" @@ -934,6 +945,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "flasgger" version = "0.9.7.1" @@ -1155,6 +1175,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "greenlet" version = "2.0.2" @@ -1369,6 +1401,10 @@ cli = [ http2 = [ { name = "h2" }, ] +opentelemetry = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, +] socks = [ { name = "socksio" }, ] @@ -1385,6 +1421,8 @@ requires-dist = [ { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "httpcore2", editable = "src/httpcore2" }, { name = "idna", specifier = ">=3.18" }, + { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.42.0" }, + { name = "opentelemetry-instrumentation", marker = "extra == 'opentelemetry'", specifier = ">=0.63b0" }, { name = "pygments", marker = "extra == 'cli'", specifier = "==2.*" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=10,<16" }, { name = "socksio", marker = "extra == 'socks'", specifier = "==1.*" }, @@ -1392,7 +1430,7 @@ requires-dist = [ { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.5.0" }, { name = "zstandard", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=0.18.0" }, ] -provides-extras = ["brotli", "cli", "http2", "socks", "zstd"] +provides-extras = ["brotli", "cli", "http2", "opentelemetry", "socks", "zstd"] [[package]] name = "hyperframe" @@ -1445,6 +1483,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inline-snapshot" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/c3/b5c36ab59e355b8d7c59205b8903fa74458c6938c9691f7d7730404f94c9/inline_snapshot-0.34.2.tar.gz", hash = "sha256:d160cb6059e00916c2e846abc014ead6f01fb24479f13696fb8670d7a7937f67", size = 2641142, upload-time = "2026-06-19T21:17:27.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2a/84fe7ef052ac666e95e7cabdf0905aa3f2c90b5a117419760b39b577602a/inline_snapshot-0.34.2-py3-none-any.whl", hash = "sha256:743a514a08ffd0d2d62878b0208d4def5041585affa7db6b89f0ca2e23552361", size = 90717, upload-time = "2026-06-19T21:17:25.629Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1680,6 +1735,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "logfire" +version = "4.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/f2/34b8ebbd6bbd82c71055d6b881b24d8ada79a0e6692d3dd8cca5e86fadb3/logfire-4.37.0.tar.gz", hash = "sha256:7ee0cb64b59c356a41a1701fb84597037f8db1fa15df7a3715ef363e5a1de06a", size = 1212176, upload-time = "2026-06-12T20:47:06.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/08/1805d2f26955671115aae555d78cc4c72a6fe733f332d44d69756bc1737b/logfire-4.37.0-py3-none-any.whl", hash = "sha256:a20823e6dbb3204614a3ea5e79c91df42405c5112393ec9d8e34ef45b60d315f", size = 378930, upload-time = "2026-06-12T20:47:03.674Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -2364,6 +2438,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -2624,6 +2794,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -3382,6 +3567,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" version = "1.23.0"