diff --git a/connectrpc-otel/connectrpc_otel/_interceptor.py b/connectrpc-otel/connectrpc_otel/_interceptor.py index df0286a..ef69d1a 100644 --- a/connectrpc-otel/connectrpc_otel/_interceptor.py +++ b/connectrpc-otel/connectrpc_otel/_interceptor.py @@ -1,8 +1,10 @@ from __future__ import annotations +import time from contextlib import AbstractContextManager, contextmanager from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast +from opentelemetry.metrics import MeterProvider, get_meter_provider from opentelemetry.propagate import get_global_textmap from opentelemetry.propagators.textmap import Setter, TextMapPropagator, default_setter from opentelemetry.trace import ( @@ -12,6 +14,7 @@ get_current_span, get_tracer_provider, ) +from opentelemetry.util.types import AttributeValue from connectrpc.errors import ConnectError @@ -19,8 +22,10 @@ CLIENT_ADDRESS, CLIENT_PORT, ERROR_TYPE, + RPC_CLIENT_CALL_DURATION, RPC_METHOD, RPC_RESPONSE_STATUS_CODE, + RPC_SERVER_CALL_DURATION, RPC_SYSTEM_NAME, SERVER_ADDRESS, SERVER_PORT, @@ -31,14 +36,12 @@ if TYPE_CHECKING: from collections.abc import Iterator, MutableMapping - from opentelemetry.util.types import AttributeValue - from connectrpc.request import RequestContext REQ = TypeVar("REQ") RES = TypeVar("RES") -Token: TypeAlias = tuple[AbstractContextManager, Span] +Token: TypeAlias = tuple[AbstractContextManager, Span, float, dict[str, AttributeValue]] # Workaround bad typing _DEFAULT_TEXTMAP_SETTER = cast("Setter[MutableMapping[str, str]]", default_setter) @@ -52,6 +55,7 @@ def __init__( *, propagator: TextMapPropagator | None = None, tracer_provider: TracerProvider | None = None, + meter_provider: MeterProvider | None = None, client: bool = False, ) -> None: """Creates a new OpenTelemetry interceptor. @@ -68,13 +72,51 @@ def __init__( self._tracer = tracer_provider.get_tracer("connectrpc-otel", __version__) self._propagator = propagator or get_global_textmap() + meter_provider = meter_provider or get_meter_provider() + meter = meter_provider.get_meter("connectrpc-otel", __version__) + + self._call_duration = meter.create_histogram( + name=(RPC_CLIENT_CALL_DURATION if client else RPC_SERVER_CALL_DURATION), + description=f"Measures the duration of an {'outgoing' if client else 'incoming'} Remote Procedure Call (RPC)", + unit="s", + explicit_bucket_boundaries_advisory=[ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10, + ], + ) + 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) + start_time = time.perf_counter() + + rpc_method = f"{ctx.method().service_name}/{ctx.method().name}" + shared_attrs: dict[str, AttributeValue] = { + RPC_SYSTEM_NAME: RpcSystemNameValues.CONNECTRPC.value, + RPC_METHOD: rpc_method, + } + + if sa := ctx.server_address(): + addr, port = sa.rsplit(":", 1) + shared_attrs[SERVER_ADDRESS] = addr + shared_attrs[SERVER_PORT] = int(port) + + cm = self._start_span(ctx, rpc_method, shared_attrs) span = cm.__enter__() - return cm, span + return cm, span, start_time, shared_attrs async def on_end( self, token: Token, ctx: RequestContext, error: Exception | None @@ -84,15 +126,28 @@ async def on_end( def on_end_sync( self, token: Token, ctx: RequestContext, error: Exception | None ) -> None: - cm, span = token - self._finish_span(span, error) + cm, span, start_time, shared_attrs = token + end_time = time.perf_counter() + error_attrs = self._get_error_attributes(error) + if error_attrs: + span.set_attributes(error_attrs) + # Won't use shared_attrs anymore, no need to copy. + metric_attrs = shared_attrs + if error_attrs: + metric_attrs.update(error_attrs) + self._call_duration.record(end_time - start_time, metric_attrs) if error: cm.__exit__(type(error), error, error.__traceback__) else: cm.__exit__(None, None, None) @contextmanager - def _start_span(self, ctx: RequestContext) -> Iterator[Span]: + def _start_span( + self, + ctx: RequestContext, + span_name: str, + shared_attrs: dict[str, AttributeValue], + ) -> Iterator[Span]: parent_otel_ctx = None if self._client: span_kind = SpanKind.CLIENT @@ -105,30 +160,27 @@ def _start_span(self, ctx: RequestContext) -> Iterator[Span]: carrier = ctx.request_headers() parent_otel_ctx = self._propagator.extract(carrier) - rpc_method = f"{ctx.method().service_name}/{ctx.method().name}" + attrs: dict[str, AttributeValue] = shared_attrs.copy() - 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 + span_name, 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__) + def _get_error_attributes( + self, error: Exception | None + ) -> dict[str, AttributeValue] | None: + if not error: + return None + + return { + ERROR_TYPE: type(error).__qualname__, + RPC_RESPONSE_STATUS_CODE: error.code.value + if isinstance(error, ConnectError) + else "unknown", + } diff --git a/connectrpc-otel/connectrpc_otel/_semconv.py b/connectrpc-otel/connectrpc_otel/_semconv.py index f29a25b..9b3e2f0 100644 --- a/connectrpc-otel/connectrpc_otel/_semconv.py +++ b/connectrpc-otel/connectrpc_otel/_semconv.py @@ -11,6 +11,8 @@ CLIENT_ADDRESS: Final = "client.address" CLIENT_PORT: Final = "client.port" ERROR_TYPE: Final = "error.type" +RPC_CLIENT_CALL_DURATION: Final = "rpc.client.call.duration" +RPC_SERVER_CALL_DURATION: Final = "rpc.server.call.duration" RPC_METHOD: Final = "rpc.method" RPC_RESPONSE_STATUS_CODE: Final = "rpc.response.status_code" RPC_SYSTEM_NAME: Final = "rpc.system.name" diff --git a/connectrpc-otel/test/test_traces.py b/connectrpc-otel/test/test_traces.py index 75bd0c0..108f17c 100644 --- a/connectrpc-otel/test/test_traces.py +++ b/connectrpc-otel/test/test_traces.py @@ -1,9 +1,7 @@ from __future__ import annotations import asyncio -import contextvars -from concurrent.futures import Future, ThreadPoolExecutor -from typing import TYPE_CHECKING, ParamSpec, cast +from typing import TYPE_CHECKING, cast import pytest from connectrpc_otel import OpenTelemetryInterceptor @@ -22,6 +20,8 @@ from opentelemetry.instrumentation.wsgi import ( OpenTelemetryMiddleware as WSGIOpenTelemetryMiddleware, ) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import Histogram, InMemoryMetricReader, Metric from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -31,11 +31,8 @@ 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 @@ -59,38 +56,6 @@ def say(self, request: SayRequest, ctx: RequestContext) -> SayResponse: 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 - - -_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[_P, object], *args: _P.args, **kwargs: _P.kwargs - ) -> 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() @@ -104,38 +69,59 @@ def tracer_provider(span_exporter: InMemorySpanExporter) -> TracerProvider: @pytest.fixture -def app_async(tracer_provider: TracerProvider) -> ElizaServiceASGIApplication: +def metric_reader() -> InMemoryMetricReader: + return InMemoryMetricReader() + + +@pytest.fixture +def meter_provider(metric_reader: InMemoryMetricReader) -> MeterProvider: + return MeterProvider(metric_readers=[metric_reader]) + + +@pytest.fixture +def app_async( + tracer_provider: TracerProvider, meter_provider: MeterProvider +) -> ElizaServiceASGIApplication: return ElizaServiceASGIApplication( ElizaServiceTest(), interceptors=[ - HostInterceptor("localhost"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider), + OpenTelemetryInterceptor( + tracer_provider=tracer_provider, meter_provider=meter_provider + ) ], ) @pytest.fixture def client_async( - app_async: ElizaServiceASGIApplication, tracer_provider: TracerProvider + app_async: ElizaServiceASGIApplication, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, ) -> 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), + OpenTelemetryInterceptor( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + client=True, + ) ], ) @pytest.fixture -def app_sync(tracer_provider: TracerProvider) -> ElizaServiceWSGIApplication: +def app_sync( + tracer_provider: TracerProvider, meter_provider: MeterProvider +) -> ElizaServiceWSGIApplication: return ElizaServiceWSGIApplication( ElizaServiceTestSync(), interceptors=[ - HostInterceptor("localhost"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider), + OpenTelemetryInterceptor( + tracer_provider=tracer_provider, meter_provider=meter_provider + ) ], ) @@ -144,15 +130,18 @@ def app_sync(tracer_provider: TracerProvider) -> ElizaServiceWSGIApplication: def client_sync( app_sync: ElizaServiceWSGIApplication, tracer_provider: TracerProvider, - executor: ContextCopyingExecutor, + meter_provider: MeterProvider, ) -> ElizaServiceClientSync: - transport = WSGITransport(app_sync, executor=executor) + transport = WSGITransport(app_sync, client=("123.456.7.89", 143)) return ElizaServiceClientSync( "http://localhost", http_client=SyncClient(transport=transport), interceptors=[ - HostInterceptor("localhost"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + OpenTelemetryInterceptor( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + client=True, + ) ], ) @@ -187,10 +176,24 @@ def app( raise ValueError(f"invalid app type {request.param}") +def get_metric_data(metric_reader: InMemoryMetricReader) -> list[Metric]: + metrics: list[Metric] = [] + data = metric_reader.get_metrics_data() + assert data is not None + for resource_metrics in data.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + if scope_metrics.scope.name == "connectrpc-otel": + metrics.extend(scope_metrics.metrics) + assert len(metrics) > 0 + metrics.sort(key=lambda m: m.name) + return metrics + + @pytest.mark.asyncio async def test_basic( client: ElizaServiceClient | ElizaServiceClientSync, span_exporter: InMemorySpanExporter, + metric_reader: InMemoryMetricReader, ) -> None: if isinstance(client, ElizaServiceClient): await client.say(SayRequest(sentence="Hi")) @@ -221,19 +224,54 @@ async def test_basic( 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 + 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 + + metrics = get_metric_data(metric_reader) + client_metric = metrics[0] + assert client_metric.name == "rpc.client.call.duration" + assert ( + client_metric.description + == "Measures the duration of an outgoing Remote Procedure Call (RPC)" + ) + assert client_metric.unit == "s" + client_histogram = cast("Histogram", client_metric.data) + assert len(client_histogram.data_points) == 1 + assert client_histogram.data_points[0].count == 1 + assert client_histogram.data_points[0].sum > 0 + assert client_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + } + + server_metric = metrics[1] + assert server_metric.name == "rpc.server.call.duration" + assert ( + server_metric.description + == "Measures the duration of an incoming Remote Procedure Call (RPC)" + ) + assert server_metric.unit == "s" + server_histogram = cast("Histogram", server_metric.data) + assert len(server_histogram.data_points) == 1 + assert server_histogram.data_points[0].count == 1 + assert server_histogram.data_points[0].sum > 0 + assert server_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + } @pytest.mark.asyncio async def test_connect_error( client: ElizaServiceClient | ElizaServiceClientSync, span_exporter: InMemorySpanExporter, + metric_reader: InMemoryMetricReader, ) -> None: with pytest.raises(ConnectError): if isinstance(client, ElizaServiceClient): @@ -261,23 +299,62 @@ async def test_connect_error( 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["error.type"] == "ConnectError" 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 + 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 + + metrics = get_metric_data(metric_reader) + client_metric = metrics[0] + assert client_metric.name == "rpc.client.call.duration" + assert ( + client_metric.description + == "Measures the duration of an outgoing Remote Procedure Call (RPC)" + ) + assert client_metric.unit == "s" + client_histogram = cast("Histogram", client_metric.data) + assert len(client_histogram.data_points) == 1 + assert client_histogram.data_points[0].count == 1 + assert client_histogram.data_points[0].sum > 0 + assert client_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + "error.type": "ConnectError", + "rpc.response.status_code": "failed_precondition", + } + + server_metric = metrics[1] + assert server_metric.name == "rpc.server.call.duration" + assert ( + server_metric.description + == "Measures the duration of an incoming Remote Procedure Call (RPC)" + ) + assert server_metric.unit == "s" + server_histogram = cast("Histogram", server_metric.data) + assert len(server_histogram.data_points) == 1 + assert server_histogram.data_points[0].count == 1 + assert server_histogram.data_points[0].sum > 0 + assert server_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + "error.type": "ConnectError", + "rpc.response.status_code": "failed_precondition", + } @pytest.mark.asyncio async def test_unknown_error( client: ElizaServiceClient | ElizaServiceClientSync, span_exporter: InMemorySpanExporter, + metric_reader: InMemoryMetricReader, ) -> None: with pytest.raises(ConnectError): if isinstance(client, ElizaServiceClient): @@ -312,16 +389,54 @@ async def test_unknown_error( 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 + 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 + assert client_attrs["error.type"] == "ConnectError" + + metrics = get_metric_data(metric_reader) + client_metric = metrics[0] + assert client_metric.name == "rpc.client.call.duration" + assert ( + client_metric.description + == "Measures the duration of an outgoing Remote Procedure Call (RPC)" + ) + assert client_metric.unit == "s" + client_histogram = cast("Histogram", client_metric.data) + assert len(client_histogram.data_points) == 1 + assert client_histogram.data_points[0].count == 1 + assert client_histogram.data_points[0].sum > 0 + assert client_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + "error.type": "ConnectError", + "rpc.response.status_code": "unknown", + } + + server_metric = metrics[1] + assert server_metric.name == "rpc.server.call.duration" + assert ( + server_metric.description + == "Measures the duration of an incoming Remote Procedure Call (RPC)" + ) + assert server_metric.unit == "s" + server_histogram = cast("Histogram", server_metric.data) + assert len(server_histogram.data_points) == 1 + assert server_histogram.data_points[0].count == 1 + assert server_histogram.data_points[0].sum > 0 + assert server_histogram.data_points[0].attributes == { + "rpc.system.name": "connectrpc", + "rpc.method": "connectrpc.eliza.v1.ElizaService/Say", + "server.address": "localhost", + "server.port": 80, + "rpc.response.status_code": "unknown", + "error.type": "ValueError", + } @pytest.mark.asyncio @@ -342,22 +457,20 @@ async def test_http_server_parent( "http://localhost", http_client=Client(transport=transport), interceptors=[ - HostInterceptor("localhost"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + 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=("123.456.7.89", 143), ) client = ElizaServiceClientSync( "http://localhost", http_client=SyncClient(transport=transport), interceptors=[ - HostInterceptor("localhost"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True) ], ) await asyncio.to_thread(client.say, SayRequest(sentence="Hi")) @@ -391,13 +504,10 @@ async def test_http_server_parent( 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 + 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 @@ -412,19 +522,17 @@ async def test_non_standard_port( "http://localhost:9123", http_client=Client(transport=transport), interceptors=[ - HostInterceptor("localhost:9123"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True) ], ) await client.say(SayRequest(sentence="Hi")) else: - transport = WSGITransport(app, executor=ContextCopyingExecutor()) + transport = WSGITransport(app, client=("123.456.7.89", 143)) client = ElizaServiceClientSync( "http://localhost:9123", http_client=SyncClient(transport=transport), interceptors=[ - HostInterceptor("localhost:9123"), - OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True), + OpenTelemetryInterceptor(tracer_provider=tracer_provider, client=True) ], ) await asyncio.to_thread(client.say, SayRequest(sentence="Hi")) @@ -453,10 +561,7 @@ async def test_non_standard_port( 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 + 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 5052ae4..d8616e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ maintainers = [ { name = "Yasushi Itoh", email = "i2y.may.roku@gmail.com" }, ] requires-python = ">= 3.10" -dependencies = ["protobuf>=5.28", "pyqwest>=0.3.3"] +dependencies = ["protobuf>=5.28", "pyqwest>=0.4.1"] readme = "README.md" license = "Apache-2.0" keywords = ["rpc", "grpc", "connect", "protobuf", "http"] diff --git a/uv.lock b/uv.lock index 698d981..51282be 100644 --- a/uv.lock +++ b/uv.lock @@ -379,7 +379,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "protobuf", specifier = ">=5.28" }, - { name = "pyqwest", specifier = ">=0.3.3" }, + { name = "pyqwest", specifier = ">=0.4.1" }, ] [package.metadata.requires-dev] @@ -662,7 +662,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1730,8 +1730,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [