From 60822c08c94ef4f61ed163fecb0c1949c3100228 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Tue, 3 Mar 2026 11:08:32 +0900 Subject: [PATCH 1/7] Add OpenTelemetry middleware Signed-off-by: Anuraag Agrawal --- .github/workflows/ci.yaml | 4 + connect-python.code-workspace | 4 + connectrpc-otel/README.md | 24 + connectrpc-otel/connectrpc_otel/__init__.py | 5 + .../connectrpc_otel/_interceptor.py | 247 ++++++++++ connectrpc-otel/connectrpc_otel/_semconv.py | 22 + connectrpc-otel/connectrpc_otel/_version.py | 5 + connectrpc-otel/pyproject.toml | 64 +++ connectrpc-otel/test/test_traces.py | 459 ++++++++++++++++++ pyproject.toml | 6 +- src/connectrpc/_client_async.py | 4 + src/connectrpc/_client_sync.py | 4 + src/connectrpc/_protocol.py | 31 +- src/connectrpc/_protocol_connect.py | 23 +- src/connectrpc/_protocol_grpc.py | 22 +- src/connectrpc/_server_async.py | 6 +- src/connectrpc/_server_sync.py | 10 +- src/connectrpc/request.py | 23 + uv.lock | 353 ++++++++++---- 19 files changed, 1223 insertions(+), 93 deletions(-) create mode 100644 connectrpc-otel/README.md create mode 100644 connectrpc-otel/connectrpc_otel/__init__.py create mode 100644 connectrpc-otel/connectrpc_otel/_interceptor.py create mode 100644 connectrpc-otel/connectrpc_otel/_semconv.py create mode 100644 connectrpc-otel/connectrpc_otel/_version.py create mode 100644 connectrpc-otel/pyproject.toml create mode 100644 connectrpc-otel/test/test_traces.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5da97cf..50849d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,6 +85,10 @@ jobs: run: uv run pytest ${{ matrix.coverage == 'cov' && '--cov=connectrpc --cov-report=xml' || '' }} working-directory: conformance + - name: run OTel tests + run: uv run pytest ${{ matrix.coverage == 'cov' && '--cov=connectrpc --cov-report=xml' || '' }} + working-directory: connectrpc-otel + - name: run Go tests run: go test ./... working-directory: protoc-gen-connect-python diff --git a/connect-python.code-workspace b/connect-python.code-workspace index bab7fae..01c93ab 100644 --- a/connect-python.code-workspace +++ b/connect-python.code-workspace @@ -8,6 +8,10 @@ "name": "conformance", "path": "./conformance", }, + { + "name": "connectrpc-otel", + "path": "./connectrpc-otel", + }, { "name": "example", "path": "./example", diff --git a/connectrpc-otel/README.md b/connectrpc-otel/README.md new file mode 100644 index 0000000..a8b42b0 --- /dev/null +++ b/connectrpc-otel/README.md @@ -0,0 +1,24 @@ +# connectrpc-otel + +OpenTelemetry middleware for connect-python to generate server and client spans +for ConnectRPC requests. + +Auto-instrumentation is currently not supported. + +## Example + +```python + +from connectrpc_otel import OpenTelemetryInterceptor + +from eliza_connect import ElizaServiceWSGIApplication, ElizaServiceClientSync + +from ._service import MyElizaService + +app = ElizaServiceWSGIApplication(MyElizaService(), interceptors=[OpenTelemetryInterceptor()]) + +def make_request(): + client = ElizaServiceClientSync("http://localhost:8080", interceptors=[OpenTelemetryInterceptor(client=True)]) + resp = client.Say(SayRequest(sentence="Hello!")) + print(resp) +``` diff --git a/connectrpc-otel/connectrpc_otel/__init__.py b/connectrpc-otel/connectrpc_otel/__init__.py new file mode 100644 index 0000000..5a050bf --- /dev/null +++ b/connectrpc-otel/connectrpc_otel/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +__all__ = ["OpenTelemetryInterceptor"] + +from ._interceptor import OpenTelemetryInterceptor diff --git a/connectrpc-otel/connectrpc_otel/_interceptor.py b/connectrpc-otel/connectrpc_otel/_interceptor.py new file mode 100644 index 0000000..0c770ab --- /dev/null +++ b/connectrpc-otel/connectrpc_otel/_interceptor.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING, TypeVar, cast + +from opentelemetry.propagate import get_global_textmap +from opentelemetry.propagators.textmap import Setter, TextMapPropagator, default_setter +from opentelemetry.trace import ( + Span, + SpanKind, + TracerProvider, + get_current_span, + get_tracer_provider, +) + +from connectrpc.errors import ConnectError + +from ._semconv import ( + CLIENT_ADDRESS, + CLIENT_PORT, + ERROR_TYPE, + RPC_METHOD, + RPC_RESPONSE_STATUS_CODE, + RPC_SYSTEM_NAME, + SERVER_ADDRESS, + SERVER_PORT, + RpcSystemNameValues, +) +from ._version import __version__ + +if TYPE_CHECKING: + from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Iterator, + MutableMapping, + ) + + from opentelemetry.util.types import AttributeValue + + from connectrpc.request import RequestContext + +REQ = TypeVar("REQ") +RES = TypeVar("RES") + +# Workaround bad typing +_DEFAULT_TEXTMAP_SETTER = cast("Setter[MutableMapping[str, str]]", default_setter) + + +class OpenTelemetryInterceptor: + """Interceptor to generate telemetry for RPC server and client requests.""" + + def __init__( + self, + *, + propagator: TextMapPropagator | None = None, + tracer_provider: TracerProvider | None = None, + client: bool = False, + ) -> None: + """Creates a new OpenTelemetry interceptor. + + Args: + propagator: The OpenTelemetry TextMapPropagator to use. If not + provided, the global default will be used. + tracer_provider: The OpenTelemetry TracerProvider to use. If not + provided, the global default will be used. + client: Whether this interceptor is for a client or server. + """ + self._client = client + tracer_provider = tracer_provider or get_tracer_provider() + self._tracer = tracer_provider.get_tracer("connectrpc-otel", __version__) + self._propagator = propagator or get_global_textmap() + + async def intercept_unary( + self, + call_next: Callable[[REQ, RequestContext], Awaitable[RES]], + request: REQ, + ctx: RequestContext, + ) -> RES: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + return await call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + async def intercept_client_stream( + self, + call_next: Callable[[AsyncIterator[REQ], RequestContext], Awaitable[RES]], + request: AsyncIterator[REQ], + ctx: RequestContext, + ) -> RES: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + return await call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + async def intercept_server_stream( + self, + call_next: Callable[[REQ, RequestContext], AsyncIterator[RES]], + request: REQ, + ctx: RequestContext, + ) -> AsyncIterator[RES]: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + async for response in call_next(request, ctx): + yield response + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + async def intercept_bidi_stream( + self, + call_next: Callable[[AsyncIterator[REQ], RequestContext], AsyncIterator[RES]], + request: AsyncIterator[REQ], + ctx: RequestContext, + ) -> AsyncIterator[RES]: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + async for response in call_next(request, ctx): + yield response + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + def intercept_unary_sync( + self, + call_next: Callable[[REQ, RequestContext], RES], + request: REQ, + ctx: RequestContext, + ) -> RES: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + return call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + def intercept_client_stream_sync( + self, + call_next: Callable[[Iterator[REQ], RequestContext], RES], + request: Iterator[REQ], + ctx: RequestContext, + ) -> RES: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + return call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + def intercept_server_stream_sync( + self, + call_next: Callable[[REQ, RequestContext], Iterator[RES]], + request: REQ, + ctx: RequestContext, + ) -> Iterator[RES]: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + yield from call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + def intercept_bidi_stream_sync( + self, + call_next: Callable[[Iterator[REQ], RequestContext], Iterator[RES]], + request: Iterator[REQ], + ctx: RequestContext, + ) -> Iterator[RES]: + error: Exception | None = None + with self._start_span(ctx) as span: + try: + yield from call_next(request, ctx) + except Exception as e: + error = e + raise + finally: + self._finish_span(span, error) + + @contextmanager + def _start_span(self, ctx: RequestContext) -> Iterator[Span]: + parent_otel_ctx = None + if self._client: + span_kind = SpanKind.CLIENT + carrier = ctx.request_headers() + self._propagator.inject(carrier, setter=_DEFAULT_TEXTMAP_SETTER) + else: + span_kind = SpanKind.SERVER + parent_span = get_current_span() + if not parent_span.get_span_context().is_valid: + carrier = ctx.request_headers() + parent_otel_ctx = self._propagator.extract(carrier) + + span_kind = SpanKind.CLIENT if self._client else SpanKind.SERVER + + rpc_method = f"{ctx.method().service_name}/{ctx.method().name}" + + attrs: MutableMapping[str, AttributeValue] = { + RPC_SYSTEM_NAME: RpcSystemNameValues.CONNECTRPC.value, + RPC_METHOD: rpc_method, + } + if sa := ctx.server_address(): + addr, port = sa.rsplit(":", 1) + attrs[SERVER_ADDRESS] = addr + attrs[SERVER_PORT] = int(port) + if ca := ctx.client_address(): + addr, port = ca.rsplit(":", 1) + attrs[CLIENT_ADDRESS] = addr + attrs[CLIENT_PORT] = int(port) + + with self._tracer.start_as_current_span( + rpc_method, kind=span_kind, attributes=attrs, context=parent_otel_ctx + ) as span: + yield span + + def _finish_span(self, span: Span, error: Exception | None) -> None: + if error: + if isinstance(error, ConnectError): + span.set_attribute(RPC_RESPONSE_STATUS_CODE, error.code.value) + else: + span.set_attribute(RPC_RESPONSE_STATUS_CODE, "unknown") + span.set_attribute(ERROR_TYPE, type(error).__qualname__) diff --git a/connectrpc-otel/connectrpc_otel/_semconv.py b/connectrpc-otel/connectrpc_otel/_semconv.py new file mode 100644 index 0000000..f29a25b --- /dev/null +++ b/connectrpc-otel/connectrpc_otel/_semconv.py @@ -0,0 +1,22 @@ +# Vendored in OpenTelemetry semantic conventions for connect-python to avoid +# unstable imports. We don't copy docstrings since for us they are implementation +# details and should be obvious enough. + +# https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-semantic-conventions/src/opentelemetry/semconv/_incubating/attributes/rpc_attributes.py +from __future__ import annotations + +from enum import Enum +from typing import Final + +CLIENT_ADDRESS: Final = "client.address" +CLIENT_PORT: Final = "client.port" +ERROR_TYPE: Final = "error.type" +RPC_METHOD: Final = "rpc.method" +RPC_RESPONSE_STATUS_CODE: Final = "rpc.response.status_code" +RPC_SYSTEM_NAME: Final = "rpc.system.name" +SERVER_ADDRESS: Final = "server.address" +SERVER_PORT: Final = "server.port" + + +class RpcSystemNameValues(Enum): + CONNECTRPC = "connectrpc" diff --git a/connectrpc-otel/connectrpc_otel/_version.py b/connectrpc-otel/connectrpc_otel/_version.py new file mode 100644 index 0000000..a9d967f --- /dev/null +++ b/connectrpc-otel/connectrpc_otel/_version.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from importlib.metadata import version + +__version__ = version("connectrpc-otel") diff --git a/connectrpc-otel/pyproject.toml b/connectrpc-otel/pyproject.toml new file mode 100644 index 0000000..ddd7683 --- /dev/null +++ b/connectrpc-otel/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "connectrpc-otel" +version = "0.8.1" +description = "OpenTelemetry middleware for connectrpc" +maintainers = [ + { name = "Anuraag Agrawal", email = "anuraaga@gmail.com" }, + { name = "Spencer Nelson", email = "spencer@firetiger.com" }, + { name = "Stefan VanBuren", email = "svanburen@buf.build" }, + { name = "Yasushi Itoh", email = "i2y.may.roku@gmail.com" }, +] +requires-python = ">= 3.10" +readme = "README.md" +license = "Apache-2.0" +keywords = [ + "opentelemetry", + "otel", + "connectrpc", + "connect-python", + "middleware", + "tracing", + "observability", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Go", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Compilers", + "Topic :: System :: Networking", +] +dependencies = ["connect-python>=0.8.0", "opentelemetry-api>=1.39.1"] + +[dependency-groups] +dev = [ + "opentelemetry-sdk==1.39.1", + "opentelemetry-instrumentation-asgi==0.60b1", + "opentelemetry-instrumentation-wsgi==0.60b1", + + "connect-python-example", + "pytest", +] + +[project.urls] +Homepage = "https://github.com/connectrpc/connect-python" +Repository = "https://github.com/connectrpc/connect-python" +Issues = "https://github.com/connectrpc/connect-python/issues" + +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "connectrpc_otel" +module-root = "" diff --git a/connectrpc-otel/test/test_traces.py b/connectrpc-otel/test/test_traces.py new file mode 100644 index 0000000..0bcafb3 --- /dev/null +++ b/connectrpc-otel/test/test_traces.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import asyncio +import contextvars +from concurrent.futures import Future, ThreadPoolExecutor +from typing import TYPE_CHECKING, cast + +import pytest +from connectrpc_otel import OpenTelemetryInterceptor +from example.eliza_connect import ( + ElizaService, + ElizaServiceASGIApplication, + ElizaServiceClient, + ElizaServiceClientSync, + ElizaServiceSync, + ElizaServiceWSGIApplication, +) +from example.eliza_pb2 import SayRequest, SayResponse +from opentelemetry.instrumentation.asgi import ( + OpenTelemetryMiddleware as ASGIOpenTelemetryMiddleware, +) +from opentelemetry.instrumentation.wsgi import ( + OpenTelemetryMiddleware as WSGIOpenTelemetryMiddleware, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import SpanKind, StatusCode +from pyqwest import Client, SyncClient +from pyqwest.testing import ASGITransport, WSGITransport + +from connectrpc.code import Code +from connectrpc.errors import ConnectError +from connectrpc.interceptor import MetadataInterceptor, MetadataInterceptorSync + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from asgiref.typing import ASGIApplication + + from connectrpc.request import RequestContext + + +class ElizaServiceTest(ElizaService): + async def say(self, request: SayRequest, ctx: RequestContext) -> SayResponse: + if request.sentence == "connect error": + raise ConnectError(Code.FAILED_PRECONDITION, "connect error") + if request.sentence == "unknown error": + raise ValueError("unknown error") + return SayResponse(sentence="Hello") + + +class ElizaServiceTestSync(ElizaServiceSync): + def say(self, request: SayRequest, ctx: RequestContext) -> SayResponse: + if request.sentence == "connect error": + raise ConnectError(Code.FAILED_PRECONDITION, "connect error") + if request.sentence == "unknown error": + raise ValueError("unknown error") + return SayResponse(sentence="Hello") + + +# Work around testing transports not filling host header like a normal one does. +# https://github.com/curioswitch/pyqwest/pull/117 +class HostInterceptor(MetadataInterceptorSync, MetadataInterceptor): + def __init__(self, host: str) -> None: + self._host = host + + async def on_start(self, ctx: RequestContext) -> None: + ctx.request_headers()["host"] = self._host + + def on_start_sync(self, ctx: RequestContext) -> None: + ctx.request_headers()["host"] = self._host + + +# Work around testing WSGI transport doesn't copy context by default. +# https://github.com/curioswitch/pyqwest/pull/118 +class ContextCopyingExecutor(ThreadPoolExecutor): + def submit( + self, fn: Callable[..., object], *args: object, **kwargs: object + ) -> Future: + ctx = contextvars.copy_context() + return super().submit(lambda: ctx.run(fn, *args, **kwargs)) + + +@pytest.fixture(scope="module") +def executor() -> Iterator[ContextCopyingExecutor]: + with ContextCopyingExecutor() as executor: + yield executor + + +@pytest.fixture +def span_exporter() -> InMemorySpanExporter: + return InMemorySpanExporter() + + +@pytest.fixture +def tracer_provider(span_exporter: InMemorySpanExporter) -> TracerProvider: + tp = TracerProvider() + tp.add_span_processor(SimpleSpanProcessor(span_exporter)) + return tp + + +@pytest.fixture +def app_async(tracer_provider: TracerProvider) -> ElizaServiceASGIApplication: + return ElizaServiceASGIApplication( + ElizaServiceTest(), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider), + ], + ) + + +@pytest.fixture +def client_async( + app_async: ElizaServiceASGIApplication, tracer_provider: TracerProvider +) -> ElizaServiceClient: + transport = ASGITransport(app_async, client=("123.456.7.89", 143)) + return ElizaServiceClient( + "http://localhost", + http_client=Client(transport=transport), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + + +@pytest.fixture +def app_sync(tracer_provider: TracerProvider) -> ElizaServiceWSGIApplication: + return ElizaServiceWSGIApplication( + ElizaServiceTestSync(), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider), + ], + ) + + +@pytest.fixture +def client_sync( + app_sync: ElizaServiceWSGIApplication, + tracer_provider: TracerProvider, + executor: ContextCopyingExecutor, +) -> ElizaServiceClientSync: + transport = WSGITransport(app_sync, executor=executor) + return ElizaServiceClientSync( + "http://localhost", + http_client=SyncClient(transport=transport), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + + +@pytest.fixture(params=["async", "sync"]) +def client( + request: pytest.FixtureRequest, + client_async: ElizaServiceClient, + client_sync: ElizaServiceClientSync, +) -> ElizaServiceClient | ElizaServiceClientSync: + match request.param: + case "async": + return client_async + case "sync": + return client_sync + case _: + raise ValueError(f"invalid client type {request.param}") + + +@pytest.fixture(params=["async", "sync"]) +def app( + request: pytest.FixtureRequest, + app_async: ElizaServiceASGIApplication, + app_sync: ElizaServiceWSGIApplication, +) -> ElizaServiceASGIApplication | ElizaServiceWSGIApplication: + match request.param: + case "async": + return app_async + case "sync": + return app_sync + case _: + raise ValueError(f"invalid app type {request.param}") + + +@pytest.mark.asyncio +async def test_basic( + client: ElizaServiceClient | ElizaServiceClientSync, + span_exporter: InMemorySpanExporter, +) -> None: + if isinstance(client, ElizaServiceClient): + await client.say(SayRequest(sentence="Hi")) + else: + await asyncio.to_thread(client.say, SayRequest(sentence="Hi")) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].kind == SpanKind.SERVER + assert spans[1].kind == SpanKind.CLIENT + server_trace_context = spans[0].get_span_context() + assert server_trace_context is not None + server_parent_context = spans[0].parent + assert server_parent_context is not None + client_trace_context = spans[1].get_span_context() + assert client_trace_context is not None + assert client_trace_context.trace_id == server_trace_context.trace_id + assert server_parent_context.span_id == client_trace_context.span_id + + for span in spans: + assert span.status.is_unset + attrs = span.attributes + assert attrs is not None + assert attrs["rpc.system.name"] == "connectrpc" + assert attrs["rpc.method"] == "connectrpc.eliza.v1.ElizaService/Say" + assert "rpc.response.status_code" not in attrs + assert "error.type" not in attrs + assert attrs["server.address"] == "localhost" + assert attrs["server.port"] == 80 + + # TODO: Remove guard when WSGITransport supports setting client addr + # https://github.com/curioswitch/pyqwest/pull/117 + if isinstance(client, ElizaServiceClient): + server_attrs = spans[0].attributes + assert server_attrs is not None + assert server_attrs["client.address"] == "123.456.7.89" + assert server_attrs["client.port"] == 143 + + +@pytest.mark.asyncio +async def test_connect_error( + client: ElizaServiceClient | ElizaServiceClientSync, + span_exporter: InMemorySpanExporter, +) -> None: + with pytest.raises(ConnectError): + if isinstance(client, ElizaServiceClient): + await client.say(SayRequest(sentence="connect error")) + else: + await asyncio.to_thread(client.say, SayRequest(sentence="connect error")) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].kind == SpanKind.SERVER + assert spans[1].kind == SpanKind.CLIENT + server_trace_context = spans[0].get_span_context() + assert server_trace_context is not None + server_parent_context = spans[0].parent + assert server_parent_context is not None + client_trace_context = spans[1].get_span_context() + assert client_trace_context is not None + assert client_trace_context.trace_id == server_trace_context.trace_id + assert server_parent_context.span_id == client_trace_context.span_id + + for span in spans: + assert span.status.status_code == StatusCode.ERROR + attrs = span.attributes + assert attrs is not None + assert attrs["rpc.system.name"] == "connectrpc" + assert attrs["rpc.method"] == "connectrpc.eliza.v1.ElizaService/Say" + assert attrs["rpc.response.status_code"] == "failed_precondition" + assert "error.type" not in attrs + assert attrs["server.address"] == "localhost" + assert attrs["server.port"] == 80 + + # TODO: Remove guard when WSGITransport supports setting client addr + # https://github.com/curioswitch/pyqwest/pull/117 + if isinstance(client, ElizaServiceClient): + server_attrs = spans[0].attributes + assert server_attrs is not None + assert server_attrs["client.address"] == "123.456.7.89" + assert server_attrs["client.port"] == 143 + + +@pytest.mark.asyncio +async def test_unknown_error( + client: ElizaServiceClient | ElizaServiceClientSync, + span_exporter: InMemorySpanExporter, +) -> None: + with pytest.raises(ConnectError): + if isinstance(client, ElizaServiceClient): + await client.say(SayRequest(sentence="unknown error")) + else: + await asyncio.to_thread(client.say, SayRequest(sentence="unknown error")) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].kind == SpanKind.SERVER + assert spans[1].kind == SpanKind.CLIENT + server_trace_context = spans[0].get_span_context() + assert server_trace_context is not None + server_parent_context = spans[0].parent + assert server_parent_context is not None + client_trace_context = spans[1].get_span_context() + assert client_trace_context is not None + assert client_trace_context.trace_id == server_trace_context.trace_id + assert server_parent_context.span_id == client_trace_context.span_id + + for span in spans: + assert span.status.status_code == StatusCode.ERROR + attrs = span.attributes + assert attrs is not None + assert attrs["rpc.system.name"] == "connectrpc" + assert attrs["rpc.method"] == "connectrpc.eliza.v1.ElizaService/Say" + assert attrs["rpc.response.status_code"] == "unknown" + assert attrs["server.address"] == "localhost" + assert attrs["server.port"] == 80 + + server_attrs = spans[0].attributes + assert server_attrs is not None + # Server sees the ValueError itself + assert server_attrs["error.type"] == "ValueError" + # TODO: Remove guard when WSGITransport supports setting client addr + # https://github.com/curioswitch/pyqwest/pull/117 + if isinstance(client, ElizaServiceClient): + assert server_attrs["client.address"] == "123.456.7.89" + assert server_attrs["client.port"] == 143 + + client_attrs = spans[1].attributes + assert client_attrs is not None + # Client just sees a ConnectError + assert "error.type" not in client_attrs + + +@pytest.mark.asyncio +async def test_http_server_parent( + app: ElizaServiceASGIApplication | ElizaServiceWSGIApplication, + tracer_provider: TracerProvider, + span_exporter: InMemorySpanExporter, +) -> None: + if isinstance(app, ElizaServiceASGIApplication): + transport = ASGITransport( + cast( + "ASGIApplication", + ASGIOpenTelemetryMiddleware(app, tracer_provider=tracer_provider), + ), + client=("123.456.7.89", 143), + ) + client = ElizaServiceClient( + "http://localhost", + http_client=Client(transport=transport), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + await client.say(SayRequest(sentence="Hi")) + else: + transport = WSGITransport( + WSGIOpenTelemetryMiddleware(app, tracer_provider=tracer_provider), + executor=ContextCopyingExecutor(), + ) + client = ElizaServiceClientSync( + "http://localhost", + http_client=SyncClient(transport=transport), + interceptors=[ + HostInterceptor("localhost"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + await asyncio.to_thread(client.say, SayRequest(sentence="Hi")) + + spans = [ + s + for s in span_exporter.get_finished_spans() + if s.instrumentation_scope and s.instrumentation_scope.name == "connectrpc-otel" + ] + assert len(spans) == 2 + assert spans[0].kind == SpanKind.SERVER + assert spans[1].kind == SpanKind.CLIENT + server_trace_context = spans[0].get_span_context() + assert server_trace_context is not None + server_parent_context = spans[0].parent + assert server_parent_context is not None + client_trace_context = spans[1].get_span_context() + assert client_trace_context is not None + assert client_trace_context.trace_id == server_trace_context.trace_id + # Parent is the server middleware span, not client span. + assert server_parent_context.span_id != client_trace_context.span_id + + for span in spans: + assert span.status.is_unset + attrs = span.attributes + assert attrs is not None + assert attrs["rpc.system.name"] == "connectrpc" + assert attrs["rpc.method"] == "connectrpc.eliza.v1.ElizaService/Say" + assert "rpc.response.status_code" not in attrs + assert "error.type" not in attrs + assert attrs["server.address"] == "localhost" + assert attrs["server.port"] == 80 + + # TODO: Remove guard when WSGITransport supports setting client addr + # https://github.com/curioswitch/pyqwest/pull/117 + if isinstance(client, ElizaServiceClient): + server_attrs = spans[0].attributes + assert server_attrs is not None + assert server_attrs["client.address"] == "123.456.7.89" + assert server_attrs["client.port"] == 143 + + +@pytest.mark.asyncio +async def test_non_standard_port( + app: ElizaServiceASGIApplication | ElizaServiceWSGIApplication, + tracer_provider: TracerProvider, + span_exporter: InMemorySpanExporter, +) -> None: + if isinstance(app, ElizaServiceASGIApplication): + transport = ASGITransport(app, client=("123.456.7.89", 143)) + client = ElizaServiceClient( + "http://localhost:9123", + http_client=Client(transport=transport), + interceptors=[ + HostInterceptor("localhost:9123"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + await client.say(SayRequest(sentence="Hi")) + else: + transport = WSGITransport(app, executor=ContextCopyingExecutor()) + client = ElizaServiceClientSync( + "http://localhost:9123", + http_client=SyncClient(transport=transport), + interceptors=[ + HostInterceptor("localhost:9123"), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + ], + ) + await asyncio.to_thread(client.say, SayRequest(sentence="Hi")) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].kind == SpanKind.SERVER + assert spans[1].kind == SpanKind.CLIENT + server_trace_context = spans[0].get_span_context() + assert server_trace_context is not None + server_parent_context = spans[0].parent + assert server_parent_context is not None + client_trace_context = spans[1].get_span_context() + assert client_trace_context is not None + assert client_trace_context.trace_id == server_trace_context.trace_id + assert server_parent_context.span_id == client_trace_context.span_id + + for span in spans: + assert span.status.is_unset + attrs = span.attributes + assert attrs is not None + assert attrs["rpc.system.name"] == "connectrpc" + assert attrs["rpc.method"] == "connectrpc.eliza.v1.ElizaService/Say" + assert "rpc.response.status_code" not in attrs + assert "error.type" not in attrs + assert attrs["server.address"] == "localhost" + assert attrs["server.port"] == 9123 + + # TODO: Remove guard when WSGITransport supports setting client addr + # https://github.com/curioswitch/pyqwest/pull/117 + if isinstance(client, ElizaServiceClient): + server_attrs = spans[0].attributes + assert server_attrs is not None + assert server_attrs["client.address"] == "123.456.7.89" + assert server_attrs["client.port"] == 143 diff --git a/pyproject.toml b/pyproject.toml index 766338b..2f49d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -231,18 +231,20 @@ exclude = [ "**/node_modules", "**/__pycache__", "**/.*", + ".venv", # GRPC python files don't typecheck on their own. # See https://github.com/grpc/grpc/issues/39555 "**/*_pb2_grpc.py", - # TODO: Work out the import issues to allow it to work.p + # TODO: Work out the import issues to allow it to work. "conformance/**", ] [tool.uv.workspace] -members = ["example"] +members = ["connectrpc-otel", "example"] [tool.uv.sources] connect-python = { workspace = true } connect-python-example = { workspace = true } +connectrpc-otel = { workspace = true } diff --git a/src/connectrpc/_client_async.py b/src/connectrpc/_client_async.py index 1e96448..0bda168 100644 --- a/src/connectrpc/_client_async.py +++ b/src/connectrpc/_client_async.py @@ -197,6 +197,7 @@ async def execute_unary( ) -> RES: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="GET" if use_get else "POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -217,6 +218,7 @@ async def execute_client_stream( ) -> RES: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -237,6 +239,7 @@ def execute_server_stream( ) -> AsyncIterator[RES]: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -257,6 +260,7 @@ def execute_bidi_stream( ) -> AsyncIterator[RES]: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, diff --git a/src/connectrpc/_client_sync.py b/src/connectrpc/_client_sync.py index 47a1938..8e44007 100644 --- a/src/connectrpc/_client_sync.py +++ b/src/connectrpc/_client_sync.py @@ -193,6 +193,7 @@ def execute_unary( ) -> RES: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="GET" if use_get else "POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -213,6 +214,7 @@ def execute_client_stream( ) -> RES: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -233,6 +235,7 @@ def execute_server_stream( ) -> Iterator[RES]: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, @@ -253,6 +256,7 @@ def execute_bidi_stream( ) -> Iterator[RES]: ctx = self._protocol.create_request_context( method=method, + url=self._address, http_method="POST", user_headers=headers, timeout_ms=timeout_ms or self._timeout_ms, diff --git a/src/connectrpc/_protocol.py b/src/connectrpc/_protocol.py index 7009f29..acb975c 100644 --- a/src/connectrpc/_protocol.py +++ b/src/connectrpc/_protocol.py @@ -179,7 +179,12 @@ def to_json_bytes(self) -> bytes: class ServerProtocol(Protocol): def create_request_context( - self, method: MethodInfo[REQ, RES], http_method: str, headers: Headers + self, + method: MethodInfo[REQ, RES], + http_method: str, + http_scheme: str, + headers: Headers, + client_address: str | None = None, ) -> RequestContext[REQ, RES]: """Creates a RequestContext from the HTTP method and headers.""" ... @@ -218,6 +223,7 @@ def create_request_context( self, *, method: MethodInfo[REQ, RES], + address: str, http_method: str, user_headers: Headers | Mapping[str, str] | None, timeout_ms: int | None, @@ -264,3 +270,26 @@ class HTTPException(Exception): def __init__(self, status: HTTPStatus, headers: list[tuple[str, str]]) -> None: self.status = status self.headers = headers + + +def host_to_server_address(host: str | None, http_scheme: str) -> str | None: + if host is None: + return None + if ":" not in host: + match http_scheme: + case "https": + host += ":443" + case "http": + host += ":80" + return host + + +def url_to_server_address(address: str) -> str | None: + if address.startswith("https://"): + scheme = "https" + address = address[len("https://") :] + else: + scheme = "http" + address = address[len("http://") :] + + return host_to_server_address(address, scheme) diff --git a/src/connectrpc/_protocol_connect.py b/src/connectrpc/_protocol_connect.py index d115c8d..d61fd6f 100644 --- a/src/connectrpc/_protocol_connect.py +++ b/src/connectrpc/_protocol_connect.py @@ -8,7 +8,12 @@ from ._codec import CODEC_NAME_JSON, CODEC_NAME_JSON_CHARSET_UTF8, Codec from ._compression import IdentityCompression, negotiate_compression from ._envelope import EnvelopeReader, EnvelopeWriter -from ._protocol import ConnectWireError, HTTPException +from ._protocol import ( + ConnectWireError, + HTTPException, + host_to_server_address, + url_to_server_address, +) from ._response_metadata import handle_response_trailers from ._version import __version__ from .code import Code @@ -56,7 +61,12 @@ def codec_name_from_content_type(content_type: str, *, stream: bool) -> str: class ConnectServerProtocol: def create_request_context( - self, method: MethodInfo[REQ, RES], http_method: str, headers: Headers + self, + method: MethodInfo[REQ, RES], + http_method: str, + http_scheme: str, + headers: Headers, + client_address: str | None = None, ) -> RequestContext[REQ, RES]: if method.idempotency_level == IdempotencyLevel.NO_SIDE_EFFECTS: if http_method not in ("GET", "POST"): @@ -92,11 +102,16 @@ def create_request_context( ) from e else: timeout_ms = None + + server_address = host_to_server_address(headers.get("host"), http_scheme) + return RequestContext( method=method, http_method=http_method, request_headers=headers, timeout_ms=timeout_ms, + server_address=server_address, + client_address=client_address, ) def create_envelope_writer( @@ -153,6 +168,7 @@ def create_request_context( self, *, method: MethodInfo[REQ, RES], + url: str, http_method: str, user_headers: Headers | Mapping[str, str] | None, timeout_ms: int | None, @@ -197,11 +213,14 @@ def create_request_context( if timeout_ms is not None: headers["connect-timeout-ms"] = str(timeout_ms) + server_address = url_to_server_address(url) + return RequestContext( method=method, http_method=http_method, request_headers=headers, timeout_ms=timeout_ms, + server_address=server_address, ) def validate_response( diff --git a/src/connectrpc/_protocol_grpc.py b/src/connectrpc/_protocol_grpc.py index 3fc0297..936c850 100644 --- a/src/connectrpc/_protocol_grpc.py +++ b/src/connectrpc/_protocol_grpc.py @@ -9,7 +9,12 @@ from ._compression import IdentityCompression, negotiate_compression from ._envelope import EnvelopeReader, EnvelopeWriter from ._gen.status_pb2 import Status -from ._protocol import ConnectWireError, HTTPException +from ._protocol import ( + ConnectWireError, + HTTPException, + host_to_server_address, + url_to_server_address, +) from ._response_metadata import handle_response_trailers from ._version import __version__ from .code import Code @@ -41,7 +46,12 @@ class GRPCServerProtocol: def create_request_context( - self, method: MethodInfo[REQ, RES], http_method: str, headers: Headers + self, + method: MethodInfo[REQ, RES], + http_method: str, + http_scheme: str, + headers: Headers, + client_address: str | None = None, ) -> RequestContext[REQ, RES]: if http_method != "POST": raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED, [("allow", "POST")]) @@ -49,11 +59,15 @@ def create_request_context( timeout_header = headers.get(GRPC_HEADER_TIMEOUT) timeout_ms = _parse_timeout(timeout_header) if timeout_header else None + server_address = host_to_server_address(headers.get("host"), http_scheme) + return RequestContext( method=method, http_method=http_method, request_headers=headers, timeout_ms=timeout_ms, + server_address=server_address, + client_address=client_address, ) def create_envelope_writer( @@ -149,6 +163,7 @@ def create_request_context( self, *, method: MethodInfo[REQ, RES], + url: str, http_method: str, user_headers: Headers | Mapping[str, str] | None, timeout_ms: int | None, @@ -179,11 +194,14 @@ def create_request_context( if timeout_ms is not None: headers[GRPC_HEADER_TIMEOUT] = _serialize_timeout(timeout_ms) + server_address = url_to_server_address(url) + return RequestContext( method=method, http_method=http_method, request_headers=headers, timeout_ms=timeout_ms, + server_address=server_address, ) def validate_response( diff --git a/src/connectrpc/_server_async.py b/src/connectrpc/_server_async.py index 72ad2c2..ece862a 100644 --- a/src/connectrpc/_server_async.py +++ b/src/connectrpc/_server_async.py @@ -178,7 +178,9 @@ async def __call__( raise HTTPException(HTTPStatus.NOT_FOUND, []) http_method = scope["method"] + http_scheme = scope.get("scheme", "http") headers = _process_headers(scope.get("headers", ())) + client_address = f"{ca[0]}:{ca[1]}" if (ca := scope.get("client")) else None content_type = headers.get("content-type", "") protocol = negotiate_server_protocol(content_type) @@ -188,7 +190,9 @@ async def __call__( msg = f"ASGI server does not support ASGI trailers extension but protocol for content-type '{content_type}' requires trailers" raise RuntimeError(msg) - ctx = protocol.create_request_context(endpoint.method, http_method, headers) + ctx = protocol.create_request_context( + endpoint.method, http_method, http_scheme, headers, client_address + ) is_unary = isinstance(endpoint, EndpointUnary) diff --git a/src/connectrpc/_server_sync.py b/src/connectrpc/_server_sync.py index e340acd..8e3e1ba 100644 --- a/src/connectrpc/_server_sync.py +++ b/src/connectrpc/_server_sync.py @@ -213,7 +213,13 @@ def __call__( raise HTTPException(HTTPStatus.NOT_FOUND, []) http_method = environ["REQUEST_METHOD"] + http_scheme = environ.get("wsgi.url_scheme", "http") headers = _process_headers(_normalize_wsgi_headers(environ)) + if ra := environ.get("REMOTE_ADDR"): + port = environ.get("REMOTE_PORT", "0") + client_address = f"{ra}:{port}" + else: + client_address = None content_type = headers.get("content-type", "") protocol = negotiate_server_protocol(content_type) @@ -224,7 +230,9 @@ def __call__( if not send_trailers: msg = f"WSGI server does not support WSGI trailers extension but protocol for content-type '{content_type}' requires trailers" raise RuntimeError(msg) - ctx = protocol.create_request_context(endpoint.method, http_method, headers) + ctx = protocol.create_request_context( + endpoint.method, http_method, http_scheme, headers, client_address + ) if isinstance(endpoint, EndpointUnarySync) and isinstance( protocol, ConnectServerProtocol diff --git a/src/connectrpc/request.py b/src/connectrpc/request.py index 02b92ca..a8feded 100644 --- a/src/connectrpc/request.py +++ b/src/connectrpc/request.py @@ -31,6 +31,8 @@ def __init__( http_method: str, request_headers: Headers, timeout_ms: int | None = None, + server_address: str | None = None, + client_address: str | None = None, ) -> None: """ Initialize a Context object. @@ -40,6 +42,8 @@ def __init__( self._request_headers = request_headers self._response_headers = None self._response_trailers = None + self._server_address = server_address + self._client_address = client_address if timeout_ms is None: self._end_time = None @@ -92,3 +96,22 @@ def timeout_ms(self) -> float | None: if self._end_time is None: return None return (self._end_time - time.monotonic()) * 1000.0 + + def server_address(self) -> str | None: + """ + Returns the server address for this request, if available, as a "address:port" string. + + - On the client, this is components from the URL configured when constructing the client. + - On the server, this is determined from the Host header and scheme of the request. + """ + return self._server_address + + def client_address(self) -> str | None: + """ + Returns the client address for this request, if available, as a "address:port" string. + + - On the client, this is never populated. + - On the server, this is the value provided by the server implementation, generally + the IP address and ephemeral port of the client. + """ + return self._client_address diff --git a/uv.lock b/uv.lock index 6961339..a7cf6e5 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ members = [ "connect-python", "connect-python-example", + "connectrpc-otel", ] constraints = [ { name = "coverage", specifier = "==7.13.2" }, @@ -424,6 +425,39 @@ requires-dist = [ { name = "starlette", specifier = "==0.52.1" }, ] +[[package]] +name = "connectrpc-otel" +version = "0.8.1" +source = { editable = "connectrpc-otel" } +dependencies = [ + { name = "connect-python" }, + { name = "opentelemetry-api" }, +] + +[package.dev-dependencies] +dev = [ + { name = "connect-python-example" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-sdk" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "connect-python", editable = "." }, + { name = "opentelemetry-api", specifier = ">=1.39.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "connect-python-example", editable = "example" }, + { name = "opentelemetry-instrumentation-asgi", specifier = "==0.60b1" }, + { name = "opentelemetry-instrumentation-wsgi", specifier = "==0.60b1" }, + { name = "opentelemetry-sdk", specifier = "==1.39.1" }, + { name = "pytest" }, +] + [[package]] name = "constantly" version = "23.10.4" @@ -752,63 +786,63 @@ wheels = [ [[package]] name = "grpcio" -version = "1.78.1" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/de/de568532d9907552700f80dcec38219d8d298ad9e71f5e0a095abaf2761e/grpcio-1.78.1.tar.gz", hash = "sha256:27c625532d33ace45d57e775edf1982e183ff8641c72e4e91ef7ba667a149d72", size = 12835760, upload-time = "2026-02-20T01:16:10.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/30/0534b643dafd54824769d6260b89c71d518e4ef8b5ad16b84d1ae9272978/grpcio-1.78.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:4393bef64cf26dc07cd6f18eaa5170ae4eebaafd4418e7e3a59ca9526a6fa30b", size = 5947661, upload-time = "2026-02-20T01:12:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f8/f678566655ab822da0f713789555e7eddca7ef93da99f480c63de3aa94b4/grpcio-1.78.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:917047c19cd120b40aab9a4b8a22e9ce3562f4a1343c0d62b3cd2d5199da3d67", size = 11819948, upload-time = "2026-02-20T01:12:39.709Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/a4b4210d946055f4e5a8430f2802202ae8f831b4b00d36d55055c5cf4b6a/grpcio-1.78.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff7de398bb3528d44d17e6913a7cfe639e3b15c65595a71155322df16978c5e1", size = 6519850, upload-time = "2026-02-20T01:12:42.715Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d9/a1e657a73000a71fa75ec7140ff3a8dc32eb3427560620e477c6a2735527/grpcio-1.78.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:15f6e636d1152667ddb4022b37534c161c8477274edb26a0b65b215dd0a81e97", size = 7198654, upload-time = "2026-02-20T01:12:46.164Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/a61c5bdf53c1638e657bb5eebb93c789837820e1fdb965145f05eccc2994/grpcio-1.78.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:27b5cb669603efb7883a882275db88b6b5d6b6c9f0267d5846ba8699b7ace338", size = 6727238, upload-time = "2026-02-20T01:12:48.472Z" }, - { url = "https://files.pythonhosted.org/packages/9d/3e/aa143d0687801986a29d85788c96089449f36651cd4e2a493737ae0c5be9/grpcio-1.78.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86edb3966778fa05bfdb333688fde5dc9079f9e2a9aa6a5c42e9564b7656ba04", size = 7300960, upload-time = "2026-02-20T01:12:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/30/d3/53e0f26b46417f28d14b5951fc6a1eff79c08c8a339e967c0a19ec7cf9e9/grpcio-1.78.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:849cc62eb989bc3be5629d4f3acef79be0d0ff15622201ed251a86d17fef6494", size = 8285274, upload-time = "2026-02-20T01:12:53.315Z" }, - { url = "https://files.pythonhosted.org/packages/29/d0/e0e9fd477ce86c07ed1ed1d5c34790f050b6d58bfde77b02b36e23f8b235/grpcio-1.78.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9a00992d6fafe19d648b9ccb4952200c50d8e36d0cce8cf026c56ed3fdc28465", size = 7726620, upload-time = "2026-02-20T01:12:56.498Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b5/e138a9f7810d196081b2e047c378ca12358c5906d79c42ddec41bb43d528/grpcio-1.78.1-cp310-cp310-win32.whl", hash = "sha256:f8759a1347f3b4f03d9a9d4ce8f9f31ad5e5d0144ba06ccfb1ffaeb0ba4c1e20", size = 4076778, upload-time = "2026-02-20T01:12:59.098Z" }, - { url = "https://files.pythonhosted.org/packages/4e/95/9b02316b85731df0943a635ca6d02f155f673c4f17e60be0c4892a6eb051/grpcio-1.78.1-cp310-cp310-win_amd64.whl", hash = "sha256:e840405a3f1249509892be2399f668c59b9d492068a2cf326d661a8c79e5e747", size = 4798925, upload-time = "2026-02-20T01:13:03.186Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/ad774af3b2c84f49c6d8c4a7bea4c40f02268ea8380630c28777edda463b/grpcio-1.78.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:3a8aa79bc6e004394c0abefd4b034c14affda7b66480085d87f5fbadf43b593b", size = 5951132, upload-time = "2026-02-20T01:13:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/48/9d/ad3c284bedd88c545e20675d98ae904114d8517a71b0efc0901e9166628f/grpcio-1.78.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8e1fcb419da5811deb47b7749b8049f7c62b993ba17822e3c7231e3e0ba65b79", size = 11831052, upload-time = "2026-02-20T01:13:09.604Z" }, - { url = "https://files.pythonhosted.org/packages/6d/08/20d12865e47242d03c3ade9bb2127f5b4aded964f373284cfb357d47c5ac/grpcio-1.78.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b071dccac245c32cd6b1dd96b722283b855881ca0bf1c685cf843185f5d5d51e", size = 6524749, upload-time = "2026-02-20T01:13:21.692Z" }, - { url = "https://files.pythonhosted.org/packages/c6/53/a8b72f52b253ec0cfdf88a13e9236a9d717c332b8aa5f0ba9e4699e94b55/grpcio-1.78.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d6fb962947e4fe321eeef3be1ba5ba49d32dea9233c825fcbade8e858c14aaf4", size = 7198995, upload-time = "2026-02-20T01:13:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/13/3c/ac769c8ded1bcb26bb119fb472d3374b481b3cf059a0875db9fc77139c17/grpcio-1.78.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6afd191551fd72e632367dfb083e33cd185bf9ead565f2476bba8ab864ae496", size = 6730770, upload-time = "2026-02-20T01:13:26.522Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c3/2275ef4cc5b942314321f77d66179be4097ff484e82ca34bf7baa5b1ddbc/grpcio-1.78.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b2acd83186305c0802dbc4d81ed0ec2f3e8658d7fde97cfba2f78d7372f05b89", size = 7305036, upload-time = "2026-02-20T01:13:30.923Z" }, - { url = "https://files.pythonhosted.org/packages/91/cb/3c2aa99e12cbbfc72c2ed8aa328e6041709d607d668860380e6cd00ba17d/grpcio-1.78.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5380268ab8513445740f1f77bd966d13043d07e2793487e61fd5b5d0935071eb", size = 8288641, upload-time = "2026-02-20T01:13:39.42Z" }, - { url = "https://files.pythonhosted.org/packages/0d/b2/21b89f492260ac645775d9973752ca873acfd0609d6998e9d3065a21ea2f/grpcio-1.78.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:389b77484959bdaad6a2b7dda44d7d1228381dd669a03f5660392aa0e9385b22", size = 7730967, upload-time = "2026-02-20T01:13:41.697Z" }, - { url = "https://files.pythonhosted.org/packages/24/03/6b89eddf87fdffb8fa9d37375d44d3a798f4b8116ac363a5f7ca84caa327/grpcio-1.78.1-cp311-cp311-win32.whl", hash = "sha256:9dee66d142f4a8cca36b5b98a38f006419138c3c89e72071747f8fca415a6d8f", size = 4076680, upload-time = "2026-02-20T01:13:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/a7/a8/204460b1bc1dff9862e98f56a2d14be3c4171f929f8eaf8c4517174b4270/grpcio-1.78.1-cp311-cp311-win_amd64.whl", hash = "sha256:43b930cf4f9c4a2262bb3e5d5bc40df426a72538b4f98e46f158b7eb112d2d70", size = 4801074, upload-time = "2026-02-20T01:13:46.315Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ed/d2eb9d27fded1a76b2a80eb9aa8b12101da7e41ce2bac0ad3651e88a14ae/grpcio-1.78.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:41e4605c923e0e9a84a2718e4948a53a530172bfaf1a6d1ded16ef9c5849fca2", size = 5913389, upload-time = "2026-02-20T01:13:49.005Z" }, - { url = "https://files.pythonhosted.org/packages/69/1b/40034e9ab010eeb3fa41ec61d8398c6dbf7062f3872c866b8f72700e2522/grpcio-1.78.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:39da1680d260c0c619c3b5fa2dc47480ca24d5704c7a548098bca7de7f5dd17f", size = 11811839, upload-time = "2026-02-20T01:13:51.839Z" }, - { url = "https://files.pythonhosted.org/packages/b4/69/fe16ef2979ea62b8aceb3a3f1e7a8bbb8b717ae2a44b5899d5d426073273/grpcio-1.78.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5d5881d72a09b8336a8f874784a8eeffacde44a7bc1a148bce5a0243a265ef0", size = 6475805, upload-time = "2026-02-20T01:13:55.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/1e/069e0a9062167db18446917d7c00ae2e91029f96078a072bedc30aaaa8c3/grpcio-1.78.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:888ceb7821acd925b1c90f0cdceaed1386e69cfe25e496e0771f6c35a156132f", size = 7169955, upload-time = "2026-02-20T01:13:59.553Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/44a57e2bb4a755e309ee4e9ed2b85c9af93450b6d3118de7e69410ee05fa/grpcio-1.78.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8942bdfc143b467c264b048862090c4ba9a0223c52ae28c9ae97754361372e42", size = 6690767, upload-time = "2026-02-20T01:14:02.31Z" }, - { url = "https://files.pythonhosted.org/packages/b8/87/21e16345d4c75046d453916166bc72a3309a382c8e97381ec4b8c1a54729/grpcio-1.78.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:716a544969660ed609164aff27b2effd3ff84e54ac81aa4ce77b1607ca917d22", size = 7266846, upload-time = "2026-02-20T01:14:12.974Z" }, - { url = "https://files.pythonhosted.org/packages/11/df/d6261983f9ca9ef4d69893765007a9a3211b91d9faf85a2591063df381c7/grpcio-1.78.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d50329b081c223d444751076bb5b389d4f06c2b32d51b31a1e98172e6cecfb9", size = 8253522, upload-time = "2026-02-20T01:14:17.407Z" }, - { url = "https://files.pythonhosted.org/packages/de/7c/4f96a0ff113c5d853a27084d7590cd53fdb05169b596ea9f5f27f17e021e/grpcio-1.78.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e836778c13ff70edada16567e8da0c431e8818eaae85b80d11c1ba5782eccbb", size = 7698070, upload-time = "2026-02-20T01:14:20.032Z" }, - { url = "https://files.pythonhosted.org/packages/17/3c/7b55c0b5af88fbeb3d0c13e25492d3ace41ac9dbd0f5f8f6c0fb613b6706/grpcio-1.78.1-cp312-cp312-win32.whl", hash = "sha256:07eb016ea7444a22bef465cce045512756956433f54450aeaa0b443b8563b9ca", size = 4066474, upload-time = "2026-02-20T01:14:22.602Z" }, - { url = "https://files.pythonhosted.org/packages/5d/17/388c12d298901b0acf10b612b650692bfed60e541672b1d8965acbf2d722/grpcio-1.78.1-cp312-cp312-win_amd64.whl", hash = "sha256:02b82dcd2fa580f5e82b4cf62ecde1b3c7cc9ba27b946421200706a6e5acaf85", size = 4797537, upload-time = "2026-02-20T01:14:25.444Z" }, - { url = "https://files.pythonhosted.org/packages/df/72/754754639cfd16ad04619e1435a518124b2d858e5752225376f9285d4c51/grpcio-1.78.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:2b7ad2981550ce999e25ce3f10c8863f718a352a2fd655068d29ea3fd37b4907", size = 5919437, upload-time = "2026-02-20T01:14:29.403Z" }, - { url = "https://files.pythonhosted.org/packages/5c/84/6267d1266f8bc335d3a8b7ccf981be7de41e3ed8bd3a49e57e588212b437/grpcio-1.78.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:409bfe22220889b9906739910a0ee4c197a967c21b8dd14b4b06dd477f8819ce", size = 11803701, upload-time = "2026-02-20T01:14:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/f3/56/c9098e8b920a54261cd605bbb040de0cde1ca4406102db0aa2c0b11d1fb4/grpcio-1.78.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34b6cb16f4b67eeb5206250dc5b4d5e8e3db939535e58efc330e4c61341554bd", size = 6479416, upload-time = "2026-02-20T01:14:35.926Z" }, - { url = "https://files.pythonhosted.org/packages/86/cf/5d52024371ee62658b7ed72480200524087528844ec1b65265bbcd31c974/grpcio-1.78.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:39d21fd30d38a5afb93f0e2e71e2ec2bd894605fb75d41d5a40060c2f98f8d11", size = 7174087, upload-time = "2026-02-20T01:14:39.98Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/5e59551afad4279e27335a6d60813b8aa3ae7b14fb62cea1d329a459c118/grpcio-1.78.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09fbd4bcaadb6d8604ed1504b0bdf7ac18e48467e83a9d930a70a7fefa27e862", size = 6692881, upload-time = "2026-02-20T01:14:42.466Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/940062de2d14013c02f51b079eb717964d67d46f5d44f22038975c9d9576/grpcio-1.78.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db681513a1bdd879c0b24a5a6a70398da5eaaba0e077a306410dc6008426847a", size = 7269092, upload-time = "2026-02-20T01:14:45.826Z" }, - { url = "https://files.pythonhosted.org/packages/09/87/9db657a4b5f3b15560ec591db950bc75a1a2f9e07832578d7e2b23d1a7bd/grpcio-1.78.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f81816faa426da461e9a597a178832a351d6f1078102590a4b32c77d251b71eb", size = 8252037, upload-time = "2026-02-20T01:14:48.57Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/b980e0265479ec65e26b6e300a39ceac33ecb3f762c2861d4bac990317cf/grpcio-1.78.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffbb760df1cd49e0989f9826b2fd48930700db6846ac171eaff404f3cfbe5c28", size = 7695243, upload-time = "2026-02-20T01:14:51.376Z" }, - { url = "https://files.pythonhosted.org/packages/98/46/5fc42c100ab702fa1ea41a75c890c563c3f96432b4a287d5a6369654f323/grpcio-1.78.1-cp313-cp313-win32.whl", hash = "sha256:1a56bf3ee99af5cf32d469de91bf5de79bdac2e18082b495fc1063ea33f4f2d0", size = 4065329, upload-time = "2026-02-20T01:14:53.952Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/806d60bb6611dfc16cf463d982bd92bd8b6bd5f87dfac66b0a44dfe20995/grpcio-1.78.1-cp313-cp313-win_amd64.whl", hash = "sha256:8991c2add0d8505178ff6c3ae54bd9386279e712be82fa3733c54067aae9eda1", size = 4797637, upload-time = "2026-02-20T01:14:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/96/3a/2d2ec4d2ce2eb9d6a2b862630a0d9d4ff4239ecf1474ecff21442a78612a/grpcio-1.78.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:d101fe49b1e0fb4a7aa36ed0c3821a0f67a5956ef572745452d2cd790d723a3f", size = 5920256, upload-time = "2026-02-20T01:15:00.23Z" }, - { url = "https://files.pythonhosted.org/packages/9c/92/dccb7d087a1220ed358753945230c1ddeeed13684b954cb09db6758f1271/grpcio-1.78.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:5ce1855e8cfc217cdf6bcfe0cf046d7cf81ddcc3e6894d6cfd075f87a2d8f460", size = 11813749, upload-time = "2026-02-20T01:15:03.312Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/c20e87f87986da9998f30f14776ce27e61f02482a3a030ffe265089342c6/grpcio-1.78.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd26048d066b51f39fe9206e2bcc2cea869a5e5b2d13c8d523f4179193047ebd", size = 6488739, upload-time = "2026-02-20T01:15:14.349Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c2/088bd96e255133d7d87c3eed0d598350d16cde1041bdbe2bb065967aaf91/grpcio-1.78.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b8d7fda614cf2af0f73bbb042f3b7fee2ecd4aea69ec98dbd903590a1083529", size = 7173096, upload-time = "2026-02-20T01:15:17.687Z" }, - { url = "https://files.pythonhosted.org/packages/60/ce/168db121073a03355ce3552b3b1f790b5ded62deffd7d98c5f642b9d3d81/grpcio-1.78.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:656a5bd142caeb8b1efe1fe0b4434ecc7781f44c97cfc7927f6608627cf178c0", size = 6693861, upload-time = "2026-02-20T01:15:20.911Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d0/90b30ec2d9425215dd56922d85a90babbe6ee7e8256ba77d866b9c0d3aba/grpcio-1.78.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:99550e344482e3c21950c034f74668fccf8a546d50c1ecb4f717543bbdc071ba", size = 7278083, upload-time = "2026-02-20T01:15:23.698Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fb/73f9ba0b082bcd385d46205095fd9c917754685885b28fce3741e9f54529/grpcio-1.78.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8f27683ca68359bd3f0eb4925824d71e538f84338b3ae337ead2ae43977d7541", size = 8252546, upload-time = "2026-02-20T01:15:26.517Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/6a89ea3cb5db6c3d9ed029b0396c49f64328c0cf5d2630ffeed25711920a/grpcio-1.78.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a40515b69ac50792f9b8ead260f194ba2bb3285375b6c40c7ff938f14c3df17d", size = 7696289, upload-time = "2026-02-20T01:15:29.718Z" }, - { url = "https://files.pythonhosted.org/packages/3d/05/63a7495048499ef437b4933d32e59b7f737bd5368ad6fb2479e2bd83bf2c/grpcio-1.78.1-cp314-cp314-win32.whl", hash = "sha256:2c473b54ef1618f4fb85e82ff4994de18143b74efc088b91b5a935a3a45042ba", size = 4142186, upload-time = "2026-02-20T01:15:32.786Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ce/adfe7e5f701d503be7778291757452e3fab6b19acf51917c79f5d1cf7f8a/grpcio-1.78.1-cp314-cp314-win_amd64.whl", hash = "sha256:e2a6b33d1050dce2c6f563c5caf7f7cbeebf7fba8cde37ffe3803d50526900d1", size = 4932000, upload-time = "2026-02-20T01:15:36.127Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] [[package]] @@ -1189,18 +1223,18 @@ wheels = [ [[package]] name = "nodejs-wheel-binaries" -version = "24.13.1" +version = "24.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/d0/81d98b8fddc45332f79d6ad5749b1c7409fb18723545eae75d9b7e0048fb/nodejs_wheel_binaries-24.13.1.tar.gz", hash = "sha256:512659a67449a038231e2e972d49e77049d2cf789ae27db39eff4ab1ca52ac57", size = 8056, upload-time = "2026-02-12T17:31:04.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/05/c75c0940b1ebf82975d14f37176679b6f3229eae8b47b6a70d1e1dae0723/nodejs_wheel_binaries-24.14.0.tar.gz", hash = "sha256:c87b515e44b0e4a523017d8c59f26ccbd05b54fe593338582825d4b51fc91e1c", size = 8057, upload-time = "2026-02-27T02:57:30.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/04/1ffe1838306654fcb50bcf46172567d50c8e27a76f4b9e55a1971fab5c4f/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:360ac9382c651de294c23c4933a02358c4e11331294983f3cf50ca1ac32666b1", size = 54757440, upload-time = "2026-02-12T17:30:35.748Z" }, - { url = "https://files.pythonhosted.org/packages/66/f6/81ad81bc3bd919a20b110130c4fd318c7b6a5abb37eb53daa353ad908012/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:035b718946793986762cdd50deee7f5f1a8f1b0bad0f0cfd57cad5492f5ea018", size = 54932957, upload-time = "2026-02-12T17:30:40.114Z" }, - { url = "https://files.pythonhosted.org/packages/14/be/8e8a2bd50953c4c5b7e0fca07368d287917b84054dc3c93dd26a2940f0f9/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f795e9238438c4225f76fbd01e2b8e1a322116bbd0dc15a7dbd585a3ad97961e", size = 59287257, upload-time = "2026-02-12T17:30:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/58/57/92f6dfa40647702a9fa6d32393ce4595d0fc03c1daa9b245df66cc60e959/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:978328e3ad522571eb163b042dfbd7518187a13968fe372738f90fdfe8a46afc", size = 59781783, upload-time = "2026-02-12T17:30:47.387Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a5/457b984cf675cf86ace7903204b9c36edf7a2d1b4325ddf71eaf8d1027c7/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e1dc893df85299420cd2a5feea0c3f8482a719b5f7f82d5977d58718b8b78b5f", size = 61287166, upload-time = "2026-02-12T17:30:50.646Z" }, - { url = "https://files.pythonhosted.org/packages/3c/99/da515f7bc3bce35cfa6005f0e0c4e3c4042a466782b143112eb393b663be/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0e581ae219a39073dcadd398a2eb648f0707b0f5d68c565586139f919c91cbe9", size = 61870142, upload-time = "2026-02-12T17:30:54.563Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c0/22001d2c96d8200834af7d1de5e72daa3266c7270330275104c3d9ddd143/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:d4c969ea0bcb8c8b20bc6a7b4ad2796146d820278f17d4dc20229b088c833e22", size = 41185473, upload-time = "2026-02-12T17:30:57.524Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/58/8c/b057c2db3551a6fe04e93dd14e33d810ac8907891534ffcc7a051b253858/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:59bb78b8eb08c3e32186da1ef913f1c806b5473d8bd0bb4492702092747b674a", size = 54798488, upload-time = "2026-02-27T02:56:56.831Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/7e1b29c067b6625c97c81eb8b0ef37cf5ad5b62bb81e23f4bde804910ec9/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:348fa061b57625de7250d608e2d9b7c4bc170544da7e328325343860eadd59e5", size = 54972803, upload-time = "2026-02-27T02:57:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/a83f0ff12faca2a56366462e572e38ac6f5cb361877bb29e289138eb7f24/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:222dbf516ccc877afcad4e4789a81b4ee93daaa9f0ad97c464417d9597f49449", size = 59340859, upload-time = "2026-02-27T02:57:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/06fad4ae8a723ae7096b5311eba67ad8b4df5f359c0a68e366750b7fef78/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b35d6fcccfe4fb0a409392d237fbc67796bac0d357b996bc12d057a1531a238b", size = 59838751, upload-time = "2026-02-27T02:57:10.449Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/4916dadc7307c3e9bcfa43b4b6f88237932d502c66f89eb2d90fb07810db/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:519507fb74f3f2b296ab1e9f00dcc211f36bbfb93c60229e72dcdee9dafd301a", size = 61340534, upload-time = "2026-02-27T02:57:15.309Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/a8ba881ee5d04b04e0d93abc8ce501ff7292813583e97f9789eb3fc0472a/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:68c93c52ff06d704bcb5ed160b4ba04ab1b291d238aaf996b03a5396e0e9a7ed", size = 61922394, upload-time = "2026-02-27T02:57:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/60/8c/b8c5f61201c72a0c7dc694b459941f89a6defda85deff258a9940a4e2efc/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:60b83c4e98b0c7d836ac9ccb67dcb36e343691cbe62cd325799ff9ed936286f3", size = 41218783, upload-time = "2026-02-27T02:57:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/1f904bc9cbd8eece393e20840c08ba3ac03440090c3a4e95168fa6d2709f/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:78a9bd1d6b11baf1433f9fb84962ff8aa71c87d48b6434f98224bc49a2253a6e", size = 38926103, upload-time = "2026-02-27T02:57:27.458Z" }, ] [[package]] @@ -1216,6 +1250,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +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/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/db/851fa88db7441da82d50bd80f2de5ee55213782e25dc858e04d0c9961d60/opentelemetry_instrumentation_asgi-0.60b1.tar.gz", hash = "sha256:16bfbe595cd24cda309a957456d0fc2523f41bc7b076d1f2d7e98a1ad9876d6f", size = 26107, upload-time = "2025-12-11T13:36:47.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/76/1fb94367cef64420d2171157a6b9509582873bd09a6afe08a78a8d1f59d9/opentelemetry_instrumentation_asgi-0.60b1-py3-none-any.whl", hash = "sha256:d48def2dbed10294c99cfcf41ebbd0c414d390a11773a41f472d20000fcddc25", size = 16933, upload-time = "2025-12-11T13:35:40.462Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-wsgi" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/24/5632d31506a27650567fdff8f9be37fc4d98396b6331617be69bd332bf77/opentelemetry_instrumentation_wsgi-0.60b1.tar.gz", hash = "sha256:eb553eec7ebfcf2945cc10d787a265e7abadb9ed1d1ebce8b13988d44fa0cf45", size = 19167, upload-time = "2025-12-11T13:37:20.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/98/c637d9e5cab1355d6765de2304199a1d79a43aa94c33d8eddb475327d81a/opentelemetry_instrumentation_wsgi-0.60b1-py3-none-any.whl", hash = "sha256:5e7b432778ebf5a39af136227884a6ab2efc3c4e73e2dbb1d05ecf03ea196705", size = 14583, upload-time = "2025-12-11T13:36:33.164Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.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/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -1549,27 +1665,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] @@ -1923,6 +2039,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/2e/dfe68657a9638f87a4078f9b31c9ed50abb89b261a6accc03572570db2d7/winloop-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1d0fc16799bb15d5648955bc6bb1fdd4d2e62b733e0821468789aabeead82db2", size = 601514, upload-time = "2026-01-20T23:45:38.59Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "wsproto" version = "1.3.2" From 90c09bad6a0ad86c576d9d3d3e05f37f9034842a Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Tue, 3 Mar 2026 16:29:45 +0900 Subject: [PATCH 2/7] Sync all packages Signed-off-by: Anuraag Agrawal --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 50849d1..c5ae4fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -67,7 +67,7 @@ jobs: go-version-file: protoc-gen-connect-python/go.mod cache-dependency-path: "**/go.mod" - - run: uv sync + - run: uv sync --all-packages - name: run lints if: startsWith(matrix.os, 'ubuntu-') From 912e7ada8eed06725f5f6fd084e26942333911dc Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Tue, 3 Mar 2026 16:31:36 +0900 Subject: [PATCH 3/7] Cleanup Signed-off-by: Anuraag Agrawal --- connectrpc-otel/pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/connectrpc-otel/pyproject.toml b/connectrpc-otel/pyproject.toml index ddd7683..cf42231 100644 --- a/connectrpc-otel/pyproject.toml +++ b/connectrpc-otel/pyproject.toml @@ -26,7 +26,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Go", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -34,8 +33,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Topic :: Software Development :: Code Generators", - "Topic :: Software Development :: Compilers", "Topic :: System :: Networking", ] dependencies = ["connect-python>=0.8.0", "opentelemetry-api>=1.39.1"] From b11dcfdda8730531c579cc5319e22080963f5016 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Tue, 3 Mar 2026 16:35:50 +0900 Subject: [PATCH 4/7] Fix typing Signed-off-by: Anuraag Agrawal --- connectrpc-otel/test/test_traces.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/connectrpc-otel/test/test_traces.py b/connectrpc-otel/test/test_traces.py index 0bcafb3..75bd0c0 100644 --- a/connectrpc-otel/test/test_traces.py +++ b/connectrpc-otel/test/test_traces.py @@ -3,7 +3,7 @@ import asyncio import contextvars from concurrent.futures import Future, ThreadPoolExecutor -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, ParamSpec, cast import pytest from connectrpc_otel import OpenTelemetryInterceptor @@ -72,11 +72,14 @@ def on_start_sync(self, ctx: RequestContext) -> None: ctx.request_headers()["host"] = self._host +_P = ParamSpec("_P") + + # Work around testing WSGI transport doesn't copy context by default. # https://github.com/curioswitch/pyqwest/pull/118 class ContextCopyingExecutor(ThreadPoolExecutor): def submit( - self, fn: Callable[..., object], *args: object, **kwargs: object + self, fn: Callable[_P, object], *args: _P.args, **kwargs: _P.kwargs ) -> Future: ctx = contextvars.copy_context() return super().submit(lambda: ctx.run(fn, *args, **kwargs)) From 8fc9f424ef3b5fc26f6de232e2975c28df0827a0 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Mar 2026 10:09:37 +0900 Subject: [PATCH 5/7] Version Signed-off-by: Anuraag Agrawal --- connectrpc-otel/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/connectrpc-otel/pyproject.toml b/connectrpc-otel/pyproject.toml index cf42231..db687fb 100644 --- a/connectrpc-otel/pyproject.toml +++ b/connectrpc-otel/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "connectrpc-otel" -version = "0.8.1" -description = "OpenTelemetry middleware for connectrpc" +version = "0.1.0" +description = "OpenTelemetry instrumentation for connectrpc" maintainers = [ { name = "Anuraag Agrawal", email = "anuraaga@gmail.com" }, { name = "Spencer Nelson", email = "spencer@firetiger.com" }, From 2cc8fac226a034ea1bd1ef28a8096689a63b6ce5 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Mar 2026 10:19:21 +0900 Subject: [PATCH 6/7] Use metadata interceptor Signed-off-by: Anuraag Agrawal --- .../connectrpc_otel/_interceptor.py | 165 +++--------------- uv.lock | 2 +- 2 files changed, 28 insertions(+), 139 deletions(-) diff --git a/connectrpc-otel/connectrpc_otel/_interceptor.py b/connectrpc-otel/connectrpc_otel/_interceptor.py index 0c770ab..acc816d 100644 --- a/connectrpc-otel/connectrpc_otel/_interceptor.py +++ b/connectrpc-otel/connectrpc_otel/_interceptor.py @@ -1,7 +1,7 @@ from __future__ import annotations -from contextlib import contextmanager -from typing import TYPE_CHECKING, TypeVar, cast +from contextlib import AbstractContextManager, contextmanager +from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast from opentelemetry.propagate import get_global_textmap from opentelemetry.propagators.textmap import Setter, TextMapPropagator, default_setter @@ -29,13 +29,7 @@ from ._version import __version__ if TYPE_CHECKING: - from collections.abc import ( - AsyncIterator, - Awaitable, - Callable, - Iterator, - MutableMapping, - ) + from collections.abc import Iterator, MutableMapping from opentelemetry.util.types import AttributeValue @@ -44,6 +38,8 @@ REQ = TypeVar("REQ") RES = TypeVar("RES") +Token: TypeAlias = tuple[AbstractContextManager, Span] + # Workaround bad typing _DEFAULT_TEXTMAP_SETTER = cast("Setter[MutableMapping[str, str]]", default_setter) @@ -72,135 +68,28 @@ def __init__( self._tracer = tracer_provider.get_tracer("connectrpc-otel", __version__) self._propagator = propagator or get_global_textmap() - async def intercept_unary( - self, - call_next: Callable[[REQ, RequestContext], Awaitable[RES]], - request: REQ, - ctx: RequestContext, - ) -> RES: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - return await call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - async def intercept_client_stream( - self, - call_next: Callable[[AsyncIterator[REQ], RequestContext], Awaitable[RES]], - request: AsyncIterator[REQ], - ctx: RequestContext, - ) -> RES: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - return await call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - async def intercept_server_stream( - self, - call_next: Callable[[REQ, RequestContext], AsyncIterator[RES]], - request: REQ, - ctx: RequestContext, - ) -> AsyncIterator[RES]: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - async for response in call_next(request, ctx): - yield response - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - async def intercept_bidi_stream( - self, - call_next: Callable[[AsyncIterator[REQ], RequestContext], AsyncIterator[RES]], - request: AsyncIterator[REQ], - ctx: RequestContext, - ) -> AsyncIterator[RES]: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - async for response in call_next(request, ctx): - yield response - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - def intercept_unary_sync( - self, - call_next: Callable[[REQ, RequestContext], RES], - request: REQ, - ctx: RequestContext, - ) -> RES: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - return call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - def intercept_client_stream_sync( - self, - call_next: Callable[[Iterator[REQ], RequestContext], RES], - request: Iterator[REQ], - ctx: RequestContext, - ) -> RES: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - return call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - def intercept_server_stream_sync( - self, - call_next: Callable[[REQ, RequestContext], Iterator[RES]], - request: REQ, - ctx: RequestContext, - ) -> Iterator[RES]: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - yield from call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) - - def intercept_bidi_stream_sync( - self, - call_next: Callable[[Iterator[REQ], RequestContext], Iterator[RES]], - request: Iterator[REQ], - ctx: RequestContext, - ) -> Iterator[RES]: - error: Exception | None = None - with self._start_span(ctx) as span: - try: - yield from call_next(request, ctx) - except Exception as e: - error = e - raise - finally: - self._finish_span(span, error) + async def on_start(self, ctx: RequestContext) -> Token: + return self.on_start_sync(ctx) + + def on_start_sync(self, ctx: RequestContext) -> Token: + cm = self._start_span(ctx) + span = cm.__enter__() + return cm, span + + async def on_end( + self, token: Token, ctx: RequestContext, error: Exception | None + ) -> None: + self.on_end_sync(token, ctx, error) + + def on_end_sync( + self, token: Token, ctx: RequestContext, error: Exception | None + ) -> None: + cm, span = token + self._finish_span(span, error) + if error: + cm.__exit__(type(error), error, error.__traceback__) + else: + cm.__exit__(None, None, None) @contextmanager def _start_span(self, ctx: RequestContext) -> Iterator[Span]: diff --git a/uv.lock b/uv.lock index a7cf6e5..aea5cf7 100644 --- a/uv.lock +++ b/uv.lock @@ -427,7 +427,7 @@ requires-dist = [ [[package]] name = "connectrpc-otel" -version = "0.8.1" +version = "0.1.0" source = { editable = "connectrpc-otel" } dependencies = [ { name = "connect-python" }, From 02527a90f4891bdda28a2ea5fa5f6c9db1a0d768 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Mar 2026 10:45:53 +0900 Subject: [PATCH 7/7] Drift Signed-off-by: Anuraag Agrawal --- connectrpc-otel/connectrpc_otel/_interceptor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/connectrpc-otel/connectrpc_otel/_interceptor.py b/connectrpc-otel/connectrpc_otel/_interceptor.py index acc816d..df0286a 100644 --- a/connectrpc-otel/connectrpc_otel/_interceptor.py +++ b/connectrpc-otel/connectrpc_otel/_interceptor.py @@ -105,8 +105,6 @@ def _start_span(self, ctx: RequestContext) -> Iterator[Span]: carrier = ctx.request_headers() parent_otel_ctx = self._propagator.extract(carrier) - span_kind = SpanKind.CLIENT if self._client else SpanKind.SERVER - rpc_method = f"{ctx.method().service_name}/{ctx.method().name}" attrs: MutableMapping[str, AttributeValue] = {