From 6980d53720fd11653bb4ef959b0056303714c201 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 20:42:55 -0400 Subject: [PATCH 1/9] Add in-process Haberdasher client helpers for tests `haberdasher_client` and `haberdasher_client_sync` build a client against `http://localhost` over a given transport, replacing the `Client`/`SyncClient` + generated-client boilerplate repeated in most tests. They take a transport rather than an app so tests can still inspect or wrap it. Signed-off-by: Stefan VanBuren --- test/_util.py | 23 ++++++++++++++ test/test_errors.py | 76 ++++++++++++--------------------------------- 2 files changed, 43 insertions(+), 56 deletions(-) diff --git a/test/_util.py b/test/_util.py index db24edf6..32380d66 100644 --- a/test/_util.py +++ b/test/_util.py @@ -2,12 +2,21 @@ from typing import TYPE_CHECKING +from pyqwest import Client, SyncClient + from connectrpc._compression import IdentityCompression from connectrpc.compression.brotli import BrotliCompression from connectrpc.compression.gzip import GzipCompression from connectrpc.compression.zstd import ZstdCompression +from .connectrpc.example.haberdasher_connect import ( + HaberdasherClient, + HaberdasherClientSync, +) + if TYPE_CHECKING: + from pyqwest import SyncTransport, Transport + from connectrpc.compression import Compression @@ -24,3 +33,17 @@ def resolve_compression(encoding: str) -> Compression: case _: msg = f"unknown encoding '{encoding}'" raise ValueError(msg) + + +def haberdasher_client(transport: Transport, **kwargs) -> HaberdasherClient: + return HaberdasherClient( + "http://localhost", http_client=Client(transport), **kwargs + ) + + +def haberdasher_client_sync( + transport: SyncTransport, **kwargs +) -> HaberdasherClientSync: + return HaberdasherClientSync( + "http://localhost", http_client=SyncClient(transport), **kwargs + ) diff --git a/test/test_errors.py b/test/test_errors.py index 56a2fe20..75c9b6a2 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -26,11 +26,10 @@ from connectrpc.code import Code from connectrpc.errors import ConnectError +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -86,10 +85,8 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: recorded_response = response return response - http_client = SyncClient(transport=ResponseRecorder(transport)) - with ( - HaberdasherClientSync("http://localhost", http_client=http_client) as client, + haberdasher_client_sync(ResponseRecorder(transport)) as client, pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp, ): @@ -128,8 +125,7 @@ async def execute(self, request: Request) -> Response: recorded_response = response return response - http_client = Client(transport=ResponseRecorder(transport)) - async with HaberdasherClient("http://localhost", http_client=http_client) as client: + async with haberdasher_client(ResponseRecorder(transport)) as client: with pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp: await client.make_hat(request=Size(inches=10)) @@ -203,9 +199,7 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: # noqa: ARG002 ) with ( - HaberdasherClientSync( - "http://localhost", http_client=SyncClient(transport=MockTransport()) - ) as client, + haberdasher_client_sync(MockTransport()) as client, pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp, ): @@ -231,9 +225,7 @@ async def execute(self, request: Request) -> Response: # noqa: ARG002 headers=Headers(response_headers), ) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=MockTransport()) - ) as client: + async with haberdasher_client(MockTransport()) as client: with pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp: await client.make_hat(request=Size(inches=10)) assert exc_info.value.code == code @@ -449,10 +441,9 @@ def make_hat(self, _request, _ctx) -> NoReturn: raise AssertionError(msg) app = HaberdasherWSGIApplication(SleepingHaberdasher()) - http_client = SyncClient(ModifyTimeout(WSGITransport(app))) with ( - HaberdasherClientSync( - "http://localhost", timeout_ms=client_timeout_ms, http_client=http_client + haberdasher_client_sync( + ModifyTimeout(WSGITransport(app)), timeout_ms=client_timeout_ms ) as client, pytest.raises(ConnectError) as exc_info, ): @@ -489,10 +480,8 @@ async def make_hat(self, _request, _ctx) -> NoReturn: raise AssertionError(msg) app = HaberdasherASGIApplication(SleepingHaberdasher()) - http_client = Client(ModifyTimeout(ASGITransport(app))) - - async with HaberdasherClient( - "http://localhost", timeout_ms=client_timeout_ms, http_client=http_client + async with haberdasher_client( + ModifyTimeout(ASGITransport(app)), timeout_ms=client_timeout_ms ) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size(inches=10), timeout_ms=call_timeout_ms) @@ -523,10 +512,8 @@ def make_similar_hats(self, _request, _ctx) -> Iterator[Hat]: app = HaberdasherWSGIApplication(RecordingHaberdasher()) with ( - HaberdasherClientSync( - "http://localhost", - timeout_ms=client_timeout_ms, - http_client=SyncClient(WSGITransport(app)), + haberdasher_client_sync( + WSGITransport(app), timeout_ms=client_timeout_ms ) as client, pytest.raises(ConnectError) as exc_info, ): @@ -565,10 +552,8 @@ async def make_similar_hats(self, _request, _ctx) -> AsyncIterator[Hat]: yield Hat() app = HaberdasherASGIApplication(RecordingHaberdasher()) - async with HaberdasherClient( - "http://localhost", - timeout_ms=client_timeout_ms, - http_client=Client(ASGITransport(app)), + async with haberdasher_client( + ASGITransport(app), timeout_ms=client_timeout_ms ) as client: with pytest.raises(ConnectError) as exc_info: if stream: @@ -595,11 +580,8 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) - http_client = Client(transport) - async with HaberdasherClient( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client: + async with haberdasher_client(transport, timeout_ms=200) as client: with pytest.raises(ConnectError, match="Something went wrong"): await client.make_hat(request=Size(inches=10)) @@ -616,11 +598,8 @@ def make_similar_hats(self, _request: Size, _ctx: RequestContext) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) - http_client = Client(transport) - async with HaberdasherClient( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client: + async with haberdasher_client(transport, timeout_ms=200) as client: with pytest.raises(ConnectError, match="Something went wrong"): async for _ in client.make_similar_hats(request=Size(inches=10)): pass @@ -637,11 +616,8 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) - http_client = Client(transport) - async with HaberdasherClient( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client: + async with haberdasher_client(transport, timeout_ms=200) as client: with pytest.raises(ConnectError, match="We're broken"): await client.make_hat(request=Size(inches=10)) @@ -656,11 +632,8 @@ def make_similar_hats(self, _request: Size, _ctx: RequestContext) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) - http_client = Client(transport) - async with HaberdasherClient( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client: + async with haberdasher_client(transport, timeout_ms=200) as client: with pytest.raises(ConnectError, match="We're broken"): async for _ in client.make_similar_hats(request=Size(inches=10)): pass @@ -676,11 +649,8 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) - http_client = Client(transport) - async with HaberdasherClient( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client: + async with haberdasher_client(transport, timeout_ms=200) as client: with pytest.raises(ConnectError, match="Internal Server Error"): await client.make_hat(request=Size(inches=10)) @@ -695,12 +665,9 @@ def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(RaisingHaberdasher()) transport = WSGITransport(app) - http_client = SyncClient(transport) with ( - HaberdasherClientSync( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client, + haberdasher_client_sync(transport, timeout_ms=200) as client, pytest.raises(ConnectError, match="Something went wrong"), ): client.make_hat(request=Size(inches=10)) @@ -719,12 +686,9 @@ def make_similar_hats(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(RaisingHaberdasher()) transport = WSGITransport(app) - http_client = SyncClient(transport) with ( - HaberdasherClientSync( - "http://localhost", timeout_ms=200, http_client=http_client - ) as client, + haberdasher_client_sync(transport, timeout_ms=200) as client, pytest.raises(ConnectError, match="Something went wrong"), ): next(client.make_similar_hats(request=Size(inches=10))) From f7623f4d56efa75d467a6ab727a93a992334af3a Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 20:43:27 -0400 Subject: [PATCH 2/9] Use Haberdasher client helpers in remaining tests `test_roundtrip_google_compat.py` keeps its own construction since it uses a different generated client. Signed-off-by: Stefan VanBuren --- test/test_client.py | 21 +++-------- test/test_codec.py | 19 +++------- test/test_compression.py | 40 +++++++-------------- test/test_details.py | 12 ++----- test/test_http.py | 15 ++------ test/test_interceptor.py | 29 +++++----------- test/test_lifespan.py | 14 +++----- test/test_roundtrip.py | 75 +++++++++++++--------------------------- 8 files changed, 64 insertions(+), 161 deletions(-) diff --git a/test/test_client.py b/test/test_client.py index ee6c47ec..4e07e49d 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -4,10 +4,8 @@ import pytest from pyqwest import ( - Client, Request, Response, - SyncClient, SyncRequest, SyncResponse, SyncTransport, @@ -18,11 +16,10 @@ from connectrpc.client import ResponseMetadata from connectrpc.protocol import ProtocolType +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -83,9 +80,7 @@ def make_hat(self, _request, ctx): HaberdasherWSGIApplication(HeadersHaberdasherSync(headers, trailers)) ) - client = HaberdasherClientSync( - "http://localhost", http_client=SyncClient(transport=transport) - ) + client = haberdasher_client_sync(transport) with ResponseMetadata() as resp: assert resp.http_status is None @@ -123,9 +118,7 @@ async def make_hat(self, _request, ctx): HaberdasherASGIApplication(HeadersHaberdasher(headers, trailers)) ) - client = HaberdasherClient( - "http://localhost", http_client=Client(transport=transport) - ) + client = haberdasher_client(transport) with ResponseMetadata() as resp: assert resp.http_status is None @@ -176,9 +169,7 @@ async def sizes() -> AsyncIterator[Size]: transport = ContentRecorder( ASGITransport(HaberdasherASGIApplication(SizeHaberdasher())) ) - async with HaberdasherClient( - "http://localhost", protocol=protocol, http_client=Client(transport=transport) - ) as client: + async with haberdasher_client(transport, protocol=protocol) as client: await client.make_hat(Size(inches=10)) assert [hat async for hat in client.make_similar_hats(Size(inches=10))] await client.make_flexible_hat(sizes()) @@ -221,9 +212,7 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: transport = ContentRecorder( WSGITransport(HaberdasherWSGIApplication(SizeHaberdasherSync())) ) - with HaberdasherClientSync( - "http://localhost", protocol=protocol, http_client=SyncClient(transport) - ) as client: + with haberdasher_client_sync(transport, protocol=protocol) as client: client.make_hat(Size(inches=10)) assert list(client.make_similar_hats(Size(inches=10))) client.make_flexible_hat(iter([Size(inches=10)])) diff --git a/test/test_codec.py b/test/test_codec.py index bc38e867..59ad7000 100644 --- a/test/test_codec.py +++ b/test/test_codec.py @@ -3,10 +3,8 @@ import pytest from protobuf import Message from pyqwest import ( - Client, Request, Response, - SyncClient, SyncRequest, SyncResponse, SyncTransport, @@ -16,11 +14,10 @@ from connectrpc.codec import Codec +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -96,12 +93,7 @@ async def execute(self, request: Request) -> Response: HaberdasherASGIApplication(SimpleHaberdasher(), codecs=[CustomCodec()]) ) ) - client = HaberdasherClient( - "http://localhost", - http_client=Client(transport), - codec=CustomCodec(), - send_compression=None, - ) + client = haberdasher_client(transport, codec=CustomCodec(), send_compression=None) res = await client.make_hat(Size(inches=10)) assert res.size == 10 @@ -137,11 +129,8 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: HaberdasherWSGIApplication(SimpleHaberdasherSync(), codecs=[CustomCodec()]) ) ) - client = HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(transport=transport), - codec=CustomCodec(), - send_compression=None, + client = haberdasher_client_sync( + transport, codec=CustomCodec(), send_compression=None ) res = client.make_hat(Size(inches=10)) diff --git a/test/test_compression.py b/test/test_compression.py index e7c0021f..cdf78fb5 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -24,12 +24,10 @@ from connectrpc.protocol import ProtocolType from connectrpc.request import Headers -from ._util import resolve_compression +from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -62,9 +60,8 @@ async def make_hat(self, _request, _ctx): SimpleHaberdasher(), compressions=[resolve_compression(c) for c in compressions] ) with ResponseMetadata() as meta: - client = HaberdasherClient( - "http://localhost", - http_client=Client(ASGITransport(app)), + client = haberdasher_client( + ASGITransport(app), accept_compression=( ZstdCompression(), GzipCompression(), @@ -96,9 +93,8 @@ def make_hat(self, _request, _ctx): app = HaberdasherWSGIApplication( SimpleHaberdasher(), compressions=[resolve_compression(c) for c in compressions] ) - client = HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app)), + client = haberdasher_client_sync( + WSGITransport(app), accept_compression=(ZstdCompression(), GzipCompression(), BrotliCompression()), send_compression=None, ) @@ -128,11 +124,8 @@ async def make_similar_hats(self, _request, _ctx): # The server only supports the default gzip. app = HaberdasherASGIApplication(SimpleHaberdasher()) - client = HaberdasherClient( - "http://localhost", - protocol=protocol, - http_client=Client(ASGITransport(app)), - send_compression=ZstdCompression(), + client = haberdasher_client( + ASGITransport(app), protocol=protocol, send_compression=ZstdCompression() ) with pytest.raises(ConnectError) as exc_info: if stream: @@ -159,11 +152,8 @@ def make_similar_hats(self, _request, _ctx): # The server only supports the default gzip. app = HaberdasherWSGIApplication(SimpleHaberdasher()) - client = HaberdasherClientSync( - "http://localhost", - protocol=protocol, - http_client=SyncClient(WSGITransport(app)), - send_compression=ZstdCompression(), + client = haberdasher_client_sync( + WSGITransport(app), protocol=protocol, send_compression=ZstdCompression() ) with pytest.raises(ConnectError) as exc_info: if stream: @@ -228,11 +218,7 @@ async def make_similar_hats(self, request, _ctx): app = HaberdasherASGIApplication( SimpleHaberdasher(), compressions=[_XorCompression()] ) - client = HaberdasherClient( - "http://localhost", - http_client=Client(ASGITransport(app)), - send_compression=_XorCompression(), - ) + client = haberdasher_client(ASGITransport(app), send_compression=_XorCompression()) if stream: hats = [hat async for hat in client.make_similar_hats(Size(inches=10))] else: @@ -252,10 +238,8 @@ def make_similar_hats(self, request, _ctx): app = HaberdasherWSGIApplication( SimpleHaberdasher(), compressions=[_XorCompression()] ) - client = HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app)), - send_compression=_XorCompression(), + client = haberdasher_client_sync( + WSGITransport(app), send_compression=_XorCompression() ) if stream: hats = list(client.make_similar_hats(Size(inches=10))) diff --git a/test/test_details.py b/test/test_details.py index fcd1ae18..caaba258 100644 --- a/test/test_details.py +++ b/test/test_details.py @@ -5,18 +5,16 @@ import pytest from protobuf import Oneof from protobuf.wkt import Any as AnyPb, Duration, Struct, Value -from pyqwest import Client, SyncClient from pyqwest.testing import ASGITransport, WSGITransport from connectrpc._protocol import ConnectWireError from connectrpc.code import Code from connectrpc.errors import ConnectError, ErrorDetail +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -44,9 +42,7 @@ def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(DetailsHaberdasherSync()) transport = WSGITransport(app) with ( - HaberdasherClientSync( - "http://localhost", http_client=SyncClient(transport) - ) as client, + haberdasher_client_sync(transport) as client, pytest.raises(ConnectError) as exc_info, ): client.make_hat(request=Size(inches=10)) @@ -83,9 +79,7 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(DetailsHaberdasher()) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=transport) - ) as client: + async with haberdasher_client(transport) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size(inches=10)) assert exc_info.value.code == Code.RESOURCE_EXHAUSTED diff --git a/test/test_http.py b/test/test_http.py index f5420f01..757eaa98 100644 --- a/test/test_http.py +++ b/test/test_http.py @@ -20,11 +20,10 @@ from connectrpc.codec import proto_json_codec from connectrpc.errors import ConnectError +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -109,11 +108,7 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: WSGITransport(HaberdasherWSGIApplication(HeadersHaberdasherSync())) ) - client = HaberdasherClientSync( - address="http://localhost", - codec=proto_json_codec(), - http_client=SyncClient(transport=transport), - ) + client = haberdasher_client_sync(transport, codec=proto_json_codec()) hats = list(client.make_similar_hats(Size(inches=2))) assert hats == [Hat(size=2), Hat(size=3)] @@ -141,11 +136,7 @@ async def execute(self, request: Request) -> Response: ASGITransport(HaberdasherASGIApplication(HeadersHaberdasher())) ) - client = HaberdasherClient( - address="http://localhost", - codec=proto_json_codec(), - http_client=Client(transport=transport), - ) + client = haberdasher_client(transport, codec=proto_json_codec()) hats = [] async for hat in client.make_similar_hats(Size(inches=2)): diff --git a/test/test_interceptor.py b/test/test_interceptor.py index 97ce54f1..66dc1a95 100644 --- a/test/test_interceptor.py +++ b/test/test_interceptor.py @@ -13,6 +13,7 @@ from connectrpc.code import Code from connectrpc.errors import ConnectError +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -102,10 +103,8 @@ async def make_various_hats(self, request, _ctx): SimpleHaberdasher(), interceptors=(server_interceptor,) ) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - interceptors=(client_interceptor,), - http_client=Client(transport=transport), + async with haberdasher_client( + transport, interceptors=(client_interceptor,) ) as client: yield client @@ -290,10 +289,8 @@ def make_various_hats(self, request, _ctx): SimpleHaberdasherSync(), interceptors=(server_interceptor,) ) transport = WSGITransport(app) - with HaberdasherClientSync( - "http://localhost", - interceptors=(client_interceptor,), - http_client=SyncClient(transport), + with haberdasher_client_sync( + transport, interceptors=(client_interceptor,) ) as client: yield client @@ -583,9 +580,7 @@ async def make_hat(self, request, _ctx): SimpleHaberdasher(), interceptors=(EventMetadataInterceptor(), EventUnaryInterceptor()), ) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=ASGITransport(app)) - ) as client: + async with haberdasher_client(ASGITransport(app)) as client: await client.make_hat(Size(inches=10)) assert events == [ @@ -625,9 +620,7 @@ def make_hat(self, request, _ctx): SimpleHaberdasherSync(), interceptors=(EventMetadataInterceptorSync(), EventUnaryInterceptorSync()), ) - with HaberdasherClientSync( - "http://localhost", http_client=SyncClient(WSGITransport(app)) - ) as client: + with haberdasher_client_sync(WSGITransport(app)) as client: client.make_hat(Size(inches=10)) assert events == [ @@ -740,9 +733,7 @@ async def make_similar_hats(self, request, _ctx): app = HaberdasherASGIApplication( SimpleHaberdasher(), interceptors=(_ResponseMetadataInterceptor(),) ) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=ASGITransport(app)) - ) as client: + async with haberdasher_client(ASGITransport(app)) as client: with ResponseMetadata() as resp: await client.make_hat(Size(inches=10)) assert resp.headers.get("x-interceptor") == "ran" @@ -767,9 +758,7 @@ def make_similar_hats(self, request, _ctx): app = HaberdasherWSGIApplication( SimpleHaberdasherSync(), interceptors=(_ResponseMetadataInterceptor(),) ) - with HaberdasherClientSync( - "http://localhost", http_client=SyncClient(WSGITransport(app)) - ) as client: + with haberdasher_client_sync(WSGITransport(app)) as client: with ResponseMetadata() as resp: client.make_hat(Size(inches=10)) assert resp.headers.get("x-interceptor") == "ran" diff --git a/test/test_lifespan.py b/test/test_lifespan.py index 63306651..ee43da49 100644 --- a/test/test_lifespan.py +++ b/test/test_lifespan.py @@ -3,17 +3,16 @@ from collections import Counter import pytest -from pyqwest import Client from pyqwest.testing import ASGITransport from connectrpc.code import Code from connectrpc.errors import ConnectError +from ._util import haberdasher_client from .connectrpc.example.empty_connect import NoMethods, NoMethodsASGIApplication from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, ) from .connectrpc.example.haberdasher_pb import Hat, Size @@ -41,10 +40,7 @@ async def counting_haberdasher(): final_count = counter["requests"] app = HaberdasherASGIApplication(counting_haberdasher()) - async with ( - ASGITransport(app) as transport, - HaberdasherClient("http://localhost", http_client=Client(transport)) as client, - ): + async with ASGITransport(app) as transport, haberdasher_client(transport) as client: for _ in range(5): hat = await client.make_hat(Size(inches=10)) assert hat.size == 10 @@ -66,7 +62,7 @@ async def no_methods(): ASGITransport(app) as transport, # No method to invoke, so we just send an unrelated request and make sure # the error code matches expected. - HaberdasherClient("http://localhost", http_client=Client(transport)) as client, + haberdasher_client(transport) as client, ): with pytest.raises(ConnectError) as exc_info: await client.make_hat(Size(inches=10)) @@ -135,9 +131,7 @@ async def counting_haberdasher(): app = HaberdasherASGIApplication(counting_haberdasher()) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport) - ) as client: + async with haberdasher_client(transport) as client: with pytest.raises(ConnectError): await client.make_hat(Size(inches=10)) assert ( diff --git a/test/test_roundtrip.py b/test/test_roundtrip.py index 9a09820b..65b7b44f 100644 --- a/test/test_roundtrip.py +++ b/test/test_roundtrip.py @@ -15,12 +15,10 @@ from connectrpc.errors import ConnectError from connectrpc.server import DEFAULT_READ_MAX_BYTES -from ._util import resolve_compression +from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, - HaberdasherClient, - HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -43,9 +41,8 @@ def make_hat(self, request, _ctx): app = HaberdasherWSGIApplication( RoundtripHaberdasherSync(), compressions=[compression] ) - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app=app)), + with haberdasher_client_sync( + WSGITransport(app=app), codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -66,9 +63,8 @@ async def make_hat(self, request, _ctx): compression = resolve_compression(compression_name) app = HaberdasherASGIApplication(DetailsHaberdasher(), compressions=[compression]) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport), + async with haberdasher_client( + transport, codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -169,9 +165,8 @@ def app(_environ, start_response): ) return [_UNKNOWN_FIELD_RESPONSE] - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app=app)), + with haberdasher_client_sync( + WSGITransport(app=app), codec=proto_json_codec(ignore_unknown_fields=ignore_unknown_fields), ) as client: if ignore_unknown_fields: @@ -201,9 +196,8 @@ async def app(_scope, _receive, send): ) await send({"type": "http.response.body", "body": _UNKNOWN_FIELD_RESPONSE}) - async with HaberdasherClient( - "http://localhost", - http_client=Client(ASGITransport(app)), + async with haberdasher_client( + ASGITransport(app), codec=proto_json_codec(ignore_unknown_fields=ignore_unknown_fields), ) as client: if ignore_unknown_fields: @@ -224,9 +218,8 @@ def make_hat(self, request, _ctx): app = HaberdasherWSGIApplication( RoundtripHaberdasherSync(), compressions=[compression] ) - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app=app)), + with haberdasher_client_sync( + WSGITransport(app=app), send_compression=compression, accept_compression=[compression], ) as client: @@ -244,11 +237,8 @@ async def make_hat(self, request, _ctx): compression = resolve_compression("identity") app = HaberdasherASGIApplication(RoundtripHaberdasher(), compressions=[compression]) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=transport), - send_compression=compression, - accept_compression=[compression], + async with haberdasher_client( + transport, send_compression=compression, accept_compression=[compression] ) as client: response = await client.make_hat(request=Size(), use_get=True) assert response.size == 0 @@ -273,9 +263,8 @@ async def make_similar_hats(self, request, _ctx): transport = ASGITransport(app) hats: list[Hat] = [] - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=transport), + async with haberdasher_client( + transport, codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -305,9 +294,7 @@ async def make_similar_hats(self, request, _ctx): app = HaberdasherASGIApplication(StreamingHaberdasher()) hats: list[Hat] = [] - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=ASGITransport(app)) - ) as client: + async with haberdasher_client(ASGITransport(app)) as client: with pytest.raises(ConnectError) as exc_info: async for h in client.make_similar_hats( request=Size(inches=10), timeout_ms=50 @@ -360,9 +347,8 @@ def make_various_hats(self, request: Iterator[Size], _ctx) -> Iterator[Hat]: LargeHaberdasher(), read_max_bytes=100, compressions=[compression] ) transport = WSGITransport(app) - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(transport), + with haberdasher_client_sync( + transport, send_compression=compression, accept_compression=[compression], read_max_bytes=100, @@ -431,9 +417,8 @@ async def make_various_hats( LargeHaberdasher(), read_max_bytes=100, compressions=[compression] ) transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=transport), + async with haberdasher_client( + transport, send_compression=compression, accept_compression=[compression], read_max_bytes=100, @@ -479,11 +464,7 @@ def make_hat(self, _request, _ctx): app = HaberdasherWSGIApplication(FailingHaberdasher()) with ( - HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app)), - read_max_bytes=100, - ) as client, + haberdasher_client_sync(WSGITransport(app), read_max_bytes=100) as client, pytest.raises(ConnectError) as exc_info, ): client.make_hat(request=Size()) @@ -505,11 +486,7 @@ async def make_hat(self, _request, _ctx): raise ConnectError(Code.FAILED_PRECONDITION, message) app = HaberdasherASGIApplication(FailingHaberdasher()) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=ASGITransport(app)), - read_max_bytes=100, - ) as client: + async with haberdasher_client(ASGITransport(app), read_max_bytes=100) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size()) if large: @@ -538,9 +515,7 @@ def make_hat(self, request, _ctx): if unlimited else HaberdasherWSGIApplication(EchoSizeHaberdasher()) ) - with HaberdasherClientSync( - "http://localhost", http_client=SyncClient(WSGITransport(app)) - ) as client: + with haberdasher_client_sync(WSGITransport(app)) as client: if unlimited: response = client.make_hat(request=big_size) assert response.size == _BIG_DESCRIPTION_LENGTH @@ -569,9 +544,7 @@ async def make_hat(self, request, _ctx): if unlimited else HaberdasherASGIApplication(EchoSizeHaberdasher()) ) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=ASGITransport(app)) - ) as client: + async with haberdasher_client(ASGITransport(app)) as client: if unlimited: response = await client.make_hat(request=big_size) assert response.size == _BIG_DESCRIPTION_LENGTH From 6e7d3c21e07868aec6eeecd9e31d1454530b3120 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 20:45:05 -0400 Subject: [PATCH 3/9] Run interceptor tests once against sync and async clients A `client` fixture parametrized over ASGI and WSGI replaces the duplicated `client_async`/`client_sync` fixtures, and `test_intercept` and `test_intercept_error` table the four RPC types instead of one test per type and mode. `call` in `_util.py` hides the await/iteration differences, running sync clients in a worker thread. Signed-off-by: Stefan VanBuren --- test/_util.py | 34 +++ test/test_interceptor.py | 482 ++++++++++++--------------------------- 2 files changed, 180 insertions(+), 336 deletions(-) diff --git a/test/_util.py b/test/_util.py index 32380d66..14a75361 100644 --- a/test/_util.py +++ b/test/_util.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING from pyqwest import Client, SyncClient @@ -19,6 +21,8 @@ from connectrpc.compression import Compression + from .connectrpc.example.haberdasher_pb import Hat, Size + def resolve_compression(encoding: str) -> Compression: match encoding: @@ -47,3 +51,33 @@ def haberdasher_client_sync( return HaberdasherClientSync( "http://localhost", http_client=SyncClient(transport), **kwargs ) + + +async def call( + client: HaberdasherClient | HaberdasherClientSync, + method: str, + request: Size | list[Size], +) -> Hat | list[Hat]: + """Calls method on client, passing and returning streams as lists. + + A sync client runs in a worker thread, as it would in a sync program. + """ + if isinstance(client, HaberdasherClientSync): + + def run() -> Hat | list[Hat]: + req = iter(request) if isinstance(request, list) else request + result = getattr(client, method)(req) + return list(result) if isinstance(result, Iterator) else result + + return await asyncio.to_thread(run) + + async def stream(requests: list[Size]) -> AsyncIterator[Size]: + for r in requests: + yield r + + result = getattr(client, method)( + stream(request) if isinstance(request, list) else request + ) + if isinstance(result, AsyncIterator): + return [r async for r in result] + return await result diff --git a/test/test_interceptor.py b/test/test_interceptor.py index 66dc1a95..ec658c2e 100644 --- a/test/test_interceptor.py +++ b/test/test_interceptor.py @@ -13,7 +13,7 @@ from connectrpc.code import Code from connectrpc.errors import ConnectError -from ._util import haberdasher_client, haberdasher_client_sync +from ._util import call, haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -64,343 +64,174 @@ def server_interceptor(): return RequestInterceptor() -@pytest_asyncio.fixture -async def client_async( - client_interceptor: RequestInterceptor, server_interceptor: RequestInterceptor -): - class SimpleHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - if request.inches < 0: +class SimpleHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + if request.inches < 0: + raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") + return Hat(size=request.inches, color="green") + + async def make_flexible_hat(self, request, _ctx): + size = 0 + async for s in request: + if s.inches < 0: raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") - return Hat(size=request.inches, color="green") - - async def make_flexible_hat(self, request, _ctx): - size = 0 - async for s in request: - if s.inches < 0: - raise ConnectError( - Code.INVALID_ARGUMENT, "Size must be non-negative" - ) - size += s.inches - return Hat(size=size, color="red") + size += s.inches + return Hat(size=size, color="red") - async def make_similar_hats(self, request, _ctx): - if request.inches < 0: + async def make_similar_hats(self, request, _ctx): + if request.inches < 0: + raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") + yield Hat(size=request.inches, color="orange") + yield Hat(size=request.inches, color="blue") + + async def make_various_hats(self, request, _ctx): + colors = itertools.cycle(("black", "white", "gold")) + async for s in request: + if s.inches < 0: raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") - yield Hat(size=request.inches, color="orange") - yield Hat(size=request.inches, color="blue") - - async def make_various_hats(self, request, _ctx): - colors = itertools.cycle(("black", "white", "gold")) - async for s in request: - if s.inches < 0: - raise ConnectError( - Code.INVALID_ARGUMENT, "Size must be non-negative" - ) - yield Hat(size=s.inches, color=next(colors)) + yield Hat(size=s.inches, color=next(colors)) - app = HaberdasherASGIApplication( - SimpleHaberdasher(), interceptors=(server_interceptor,) - ) - transport = ASGITransport(app) - async with haberdasher_client( - transport, interceptors=(client_interceptor,) - ) as client: - yield client - -@pytest.mark.asyncio -async def test_intercept_unary_async( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - result = await client_async.make_hat(Size(inches=10)) - assert result == Hat(size=10, color="green") - assert client_interceptor.result == ["Hello MakeHat and goodbye"] - assert server_interceptor.result == ["Hello MakeHat and goodbye"] - - -@pytest.mark.asyncio -async def test_intercept_unary_async_error( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - with pytest.raises(ConnectError): - await client_async.make_hat(Size(inches=-10)) - assert client_interceptor.result == [ - "Hello MakeHat and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeHat and goodbye with error Size must be non-negative" - ] - - -@pytest.mark.asyncio -async def test_intercept_client_stream_async( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - async def requests(): - yield Size(inches=10) - yield Size(inches=20) - - result = await client_async.make_flexible_hat(requests()) - assert result == Hat(size=30, color="red") - assert client_interceptor.result == ["Hello MakeFlexibleHat and goodbye"] - assert server_interceptor.result == ["Hello MakeFlexibleHat and goodbye"] - - -@pytest.mark.asyncio -async def test_intercept_client_stream_async_error( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - async def requests(): - yield Size(inches=-10) - yield Size(inches=20) - - with pytest.raises(ConnectError): - await client_async.make_flexible_hat(requests()) - assert client_interceptor.result == [ - "Hello MakeFlexibleHat and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeFlexibleHat and goodbye with error Size must be non-negative" - ] - - -@pytest.mark.asyncio -async def test_intercept_server_stream_async( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - result = [r async for r in client_async.make_similar_hats(Size(inches=15))] - - assert result == [Hat(size=15, color="orange"), Hat(size=15, color="blue")] - assert client_interceptor.result == ["Hello MakeSimilarHats and goodbye"] - assert server_interceptor.result == ["Hello MakeSimilarHats and goodbye"] - - -@pytest.mark.asyncio -async def test_intercept_server_stream_async_error( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - with pytest.raises(ConnectError): - async for _ in client_async.make_similar_hats(Size(inches=-15)): - pass - - assert client_interceptor.result == [ - "Hello MakeSimilarHats and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeSimilarHats and goodbye with error Size must be non-negative" - ] - - -@pytest.mark.asyncio -async def test_intercept_bidi_stream_async( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - async def requests(): - yield Size(inches=25) - yield Size(inches=35) - yield Size(inches=45) - - result = [r async for r in client_async.make_various_hats(requests())] - - assert result == [ - Hat(size=25, color="black"), - Hat(size=35, color="white"), - Hat(size=45, color="gold"), - ] - assert client_interceptor.result == ["Hello MakeVariousHats and goodbye"] - assert server_interceptor.result == ["Hello MakeVariousHats and goodbye"] - - -@pytest.mark.asyncio -async def test_intercept_bidi_stream_async_error( - client_async: HaberdasherClient, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - async def requests(): - yield Size(inches=-25) - yield Size(inches=35) - yield Size(inches=45) - - with pytest.raises(ConnectError): - async for _ in client_async.make_various_hats(requests()): - pass - - assert client_interceptor.result == [ - "Hello MakeVariousHats and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeVariousHats and goodbye with error Size must be non-negative" - ] - - -@pytest.fixture -def client_sync( - client_interceptor: RequestInterceptor, server_interceptor: RequestInterceptor -): - class SimpleHaberdasherSync(HaberdasherSync): - def make_hat(self, request, _ctx): - if request.inches < 0: +class SimpleHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + if request.inches < 0: + raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") + return Hat(size=request.inches, color="green") + + def make_flexible_hat(self, request, _ctx): + size = 0 + for s in request: + if s.inches < 0: raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") - return Hat(size=request.inches, color="green") - - def make_flexible_hat(self, request, _ctx): - size = 0 - for s in request: - if s.inches < 0: - raise ConnectError( - Code.INVALID_ARGUMENT, "Size must be non-negative" - ) - size += s.inches - return Hat(size=size, color="red") + size += s.inches + return Hat(size=size, color="red") - def make_similar_hats(self, request, _ctx): - if request.inches < 0: + def make_similar_hats(self, request, _ctx): + if request.inches < 0: + raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") + yield Hat(size=request.inches, color="orange") + yield Hat(size=request.inches, color="blue") + + def make_various_hats(self, request, _ctx): + colors = itertools.cycle(("black", "white", "gold")) + requests = [*request] + for s in requests: + if s.inches < 0: raise ConnectError(Code.INVALID_ARGUMENT, "Size must be non-negative") - yield Hat(size=request.inches, color="orange") - yield Hat(size=request.inches, color="blue") - - def make_various_hats(self, request, _ctx): - colors = itertools.cycle(("black", "white", "gold")) - requests = [*request] - for s in requests: - if s.inches < 0: - raise ConnectError( - Code.INVALID_ARGUMENT, "Size must be non-negative" - ) - yield Hat(size=s.inches, color=next(colors)) - - app = HaberdasherWSGIApplication( - SimpleHaberdasherSync(), interceptors=(server_interceptor,) - ) - transport = WSGITransport(app) - with haberdasher_client_sync( - transport, interceptors=(client_interceptor,) - ) as client: - yield client - - -def test_intercept_unary_sync( - client_sync: HaberdasherClientSync, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - result = client_sync.make_hat(Size(inches=10)) - assert result == Hat(size=10, color="green") - assert client_interceptor.result == ["Hello MakeHat and goodbye"] - assert server_interceptor.result == ["Hello MakeHat and goodbye"] - - -def test_intercept_unary_sync_error( - client_sync: HaberdasherClientSync, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - with pytest.raises(ConnectError): - client_sync.make_hat(Size(inches=-10)) - assert client_interceptor.result == [ - "Hello MakeHat and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeHat and goodbye with error Size must be non-negative" - ] + yield Hat(size=s.inches, color=next(colors)) -def test_intercept_client_stream_sync( - client_sync: HaberdasherClientSync, +@pytest_asyncio.fixture(params=["async", "sync"]) +async def client( + request: pytest.FixtureRequest, client_interceptor: RequestInterceptor, server_interceptor: RequestInterceptor, -) -> None: - def requests(): - yield Size(inches=10) - yield Size(inches=20) - - result = client_sync.make_flexible_hat(requests()) - assert result == Hat(size=30, color="red") - assert client_interceptor.result == ["Hello MakeFlexibleHat and goodbye"] - assert server_interceptor.result == ["Hello MakeFlexibleHat and goodbye"] - - -def test_intercept_client_stream_sync_error( - client_sync: HaberdasherClientSync, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - def requests(): - yield Size(inches=-10) - yield Size(inches=20) - - with pytest.raises(ConnectError): - client_sync.make_flexible_hat(requests()) - assert client_interceptor.result == [ - "Hello MakeFlexibleHat and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeFlexibleHat and goodbye with error Size must be non-negative" - ] +): + if request.param == "async": + app = HaberdasherASGIApplication( + SimpleHaberdasher(), interceptors=(server_interceptor,) + ) + async with haberdasher_client( + ASGITransport(app), interceptors=(client_interceptor,) + ) as client: + yield client + else: + app = HaberdasherWSGIApplication( + SimpleHaberdasherSync(), interceptors=(server_interceptor,) + ) + with haberdasher_client_sync( + WSGITransport(app), interceptors=(client_interceptor,) + ) as client: + yield client -def test_intercept_server_stream_sync( - client_sync: HaberdasherClientSync, +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "request_", "expected", "name"), + [ + pytest.param( + "make_hat", + Size(inches=10), + Hat(size=10, color="green"), + "MakeHat", + id="unary", + ), + pytest.param( + "make_flexible_hat", + [Size(inches=10), Size(inches=20)], + Hat(size=30, color="red"), + "MakeFlexibleHat", + id="client_stream", + ), + pytest.param( + "make_similar_hats", + Size(inches=15), + [Hat(size=15, color="orange"), Hat(size=15, color="blue")], + "MakeSimilarHats", + id="server_stream", + ), + pytest.param( + "make_various_hats", + [Size(inches=25), Size(inches=35), Size(inches=45)], + [ + Hat(size=25, color="black"), + Hat(size=35, color="white"), + Hat(size=45, color="gold"), + ], + "MakeVariousHats", + id="bidi_stream", + ), + ], +) +async def test_intercept( + client: HaberdasherClient | HaberdasherClientSync, client_interceptor: RequestInterceptor, server_interceptor: RequestInterceptor, + method: str, + request_: Size | list[Size], + expected: Hat | list[Hat], + name: str, ) -> None: - result = list(client_sync.make_similar_hats(Size(inches=15))) - - assert result == [Hat(size=15, color="orange"), Hat(size=15, color="blue")] - assert client_interceptor.result == ["Hello MakeSimilarHats and goodbye"] - assert server_interceptor.result == ["Hello MakeSimilarHats and goodbye"] + assert await call(client, method, request_) == expected + assert client_interceptor.result == [f"Hello {name} and goodbye"] + assert server_interceptor.result == [f"Hello {name} and goodbye"] -def test_intercept_server_stream_sync_error( - client_sync: HaberdasherClientSync, +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "request_", "name"), + [ + pytest.param("make_hat", Size(inches=-10), "MakeHat", id="unary"), + pytest.param( + "make_flexible_hat", + [Size(inches=-10), Size(inches=20)], + "MakeFlexibleHat", + id="client_stream", + ), + pytest.param( + "make_similar_hats", Size(inches=-15), "MakeSimilarHats", id="server_stream" + ), + pytest.param( + "make_various_hats", + [Size(inches=-25), Size(inches=35), Size(inches=45)], + "MakeVariousHats", + id="bidi_stream", + ), + ], +) +async def test_intercept_error( + client: HaberdasherClient | HaberdasherClientSync, client_interceptor: RequestInterceptor, server_interceptor: RequestInterceptor, + method: str, + request_: Size | list[Size], + name: str, ) -> None: with pytest.raises(ConnectError): - list(client_sync.make_similar_hats(Size(inches=-15))) - assert client_interceptor.result == [ - "Hello MakeSimilarHats and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeSimilarHats and goodbye with error Size must be non-negative" - ] - - -def test_intercept_bidi_stream_sync( - client_sync: HaberdasherClientSync, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - def requests(): - yield Size(inches=25) - yield Size(inches=35) - yield Size(inches=45) - - result = list(client_sync.make_various_hats(requests())) - - assert result == [ - Hat(size=25, color="black"), - Hat(size=35, color="white"), - Hat(size=45, color="gold"), - ] - assert client_interceptor.result == ["Hello MakeVariousHats and goodbye"] - assert server_interceptor.result == ["Hello MakeVariousHats and goodbye"] + await call(client, method, request_) + expected = f"Hello {name} and goodbye with error Size must be non-negative" + assert client_interceptor.result == [expected] + assert server_interceptor.result == [expected] class _CountingHaberdasher(Haberdasher): @@ -768,24 +599,3 @@ def make_similar_hats(self, request, _ctx): for _ in client.make_similar_hats(Size(inches=10)): pass assert resp.trailers.get("x-interceptor-trailer") == "ran" - - -def test_intercept_bidi_stream_sync_error( - client_sync: HaberdasherClientSync, - client_interceptor: RequestInterceptor, - server_interceptor: RequestInterceptor, -) -> None: - def requests(): - yield Size(inches=-25) - yield Size(inches=35) - yield Size(inches=45) - - with pytest.raises(ConnectError): - list(client_sync.make_various_hats(requests())) - - assert client_interceptor.result == [ - "Hello MakeVariousHats and goodbye with error Size must be non-negative" - ] - assert server_interceptor.result == [ - "Hello MakeVariousHats and goodbye with error Size must be non-negative" - ] From e933a3860e0896f12d30d7720e68f95ec0096289 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 20:46:30 -0400 Subject: [PATCH 4/9] Run compression tests once against sync and async clients A `new_client` fixture parametrized over ASGI and WSGI builds a client for a shared service with the given server compressions, replacing three `_sync`/`_async` pairs. Signed-off-by: Stefan VanBuren --- test/test_compression.py | 198 +++++++++++++-------------------------- 1 file changed, 64 insertions(+), 134 deletions(-) diff --git a/test/test_compression.py b/test/test_compression.py index cdf78fb5..60116aa3 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -24,57 +24,66 @@ from connectrpc.protocol import ProtocolType from connectrpc.request import Headers -from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression +from ._util import ( + call, + haberdasher_client, + haberdasher_client_sync, + resolve_compression, +) from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) from .connectrpc.example.haberdasher_pb import Hat, Size if TYPE_CHECKING: + from collections.abc import Iterable + from connectrpc._protocol import ServerProtocol from connectrpc.compression import Compression -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("compressions", "encoding"), - [ - pytest.param((), "identity", id="none"), - pytest.param(("gzip",), "gzip", id="gzip"), - pytest.param(("zstd",), "zstd", id="zstd"), - pytest.param(("br",), "br", id="br"), - pytest.param(("gzip", "br", "zstd"), "zstd", id="all"), - ], -) -async def test_server_compressions_async( - compressions: tuple[str], encoding: str -) -> None: - class SimpleHaberdasher(Haberdasher): - async def make_hat(self, _request, _ctx): - return Hat(size=10, color="blue") +class _BlueHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") - app = HaberdasherASGIApplication( - SimpleHaberdasher(), compressions=[resolve_compression(c) for c in compressions] - ) - with ResponseMetadata() as meta: - client = haberdasher_client( - ASGITransport(app), - accept_compression=( - ZstdCompression(), - GzipCompression(), - BrotliCompression(), - ), - send_compression=None, + async def make_similar_hats(self, request, _ctx): + yield Hat(size=request.inches, color="blue") + + +class _BlueHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") + + def make_similar_hats(self, request, _ctx): + yield Hat(size=request.inches, color="blue") + + +@pytest.fixture(params=["async", "sync"]) +def new_client(request: pytest.FixtureRequest): + """Returns a factory for clients of a server with the given compressions.""" + + def new_client( + compressions: Iterable[Compression] | None = None, **kwargs + ) -> HaberdasherClient | HaberdasherClientSync: + if request.param == "async": + app = HaberdasherASGIApplication( + _BlueHaberdasher(), compressions=compressions + ) + return haberdasher_client(ASGITransport(app), **kwargs) + app = HaberdasherWSGIApplication( + _BlueHaberdasherSync(), compressions=compressions ) - res = await client.make_hat(Size(inches=10)) - assert res.size == 10 - assert res.color == "blue" - assert meta.headers.get("content-encoding") == encoding + return haberdasher_client_sync(WSGITransport(app), **kwargs) + + return new_client +@pytest.mark.asyncio @pytest.mark.parametrize( ("compressions", "encoding"), [ @@ -85,82 +94,37 @@ async def make_hat(self, _request, _ctx): pytest.param(("gzip", "br", "zstd"), "zstd", id="all"), ], ) -def test_server_compressions_sync(compressions: tuple[str], encoding: str) -> None: - class SimpleHaberdasher(HaberdasherSync): - def make_hat(self, _request, _ctx): - return Hat(size=10, color="blue") - - app = HaberdasherWSGIApplication( - SimpleHaberdasher(), compressions=[resolve_compression(c) for c in compressions] - ) - client = haberdasher_client_sync( - WSGITransport(app), +async def test_server_compressions( + new_client, compressions: tuple[str], encoding: str +) -> None: + client = new_client( + [resolve_compression(c) for c in compressions], accept_compression=(ZstdCompression(), GzipCompression(), BrotliCompression()), send_compression=None, ) with ResponseMetadata() as meta: - res = client.make_hat(Size(inches=10)) - assert res.size == 10 - assert res.color == "blue" + res = await call(client, "make_hat", Size(inches=10)) + assert res == Hat(size=10, color="blue") assert meta.headers.get("content-encoding") == encoding _protocols = [ProtocolType.CONNECT, ProtocolType.GRPC, ProtocolType.GRPC_WEB] -_streams = [pytest.param(False, id="unary"), pytest.param(True, id="stream")] +_methods = [ + pytest.param("make_hat", id="unary"), + pytest.param("make_similar_hats", id="stream"), +] @pytest.mark.asyncio @pytest.mark.parametrize("protocol", _protocols) -@pytest.mark.parametrize("stream", _streams) -async def test_unknown_request_compression_async( - protocol: ProtocolType, stream: bool +@pytest.mark.parametrize("method", _methods) +async def test_unknown_request_compression( + new_client, protocol: ProtocolType, method: str ) -> None: - class SimpleHaberdasher(Haberdasher): - async def make_hat(self, _request, _ctx): - return Hat(size=10, color="blue") - - async def make_similar_hats(self, _request, _ctx): - yield Hat(size=10, color="blue") - - # The server only supports the default gzip. - app = HaberdasherASGIApplication(SimpleHaberdasher()) - client = haberdasher_client( - ASGITransport(app), protocol=protocol, send_compression=ZstdCompression() - ) - with pytest.raises(ConnectError) as exc_info: - if stream: - async for _ in client.make_similar_hats(Size(inches=10)): - pass - else: - await client.make_hat(Size(inches=10)) - assert exc_info.value.code == Code.UNIMPLEMENTED - assert ( - exc_info.value.message - == "unknown compression: 'zstd': supported encodings are gzip" - ) - - -@pytest.mark.parametrize("protocol", _protocols) -@pytest.mark.parametrize("stream", _streams) -def test_unknown_request_compression_sync(protocol: ProtocolType, stream: bool) -> None: - class SimpleHaberdasher(HaberdasherSync): - def make_hat(self, _request, _ctx): - return Hat(size=10, color="blue") - - def make_similar_hats(self, _request, _ctx): - yield Hat(size=10, color="blue") - # The server only supports the default gzip. - app = HaberdasherWSGIApplication(SimpleHaberdasher()) - client = haberdasher_client_sync( - WSGITransport(app), protocol=protocol, send_compression=ZstdCompression() - ) + client = new_client(protocol=protocol, send_compression=ZstdCompression()) with pytest.raises(ConnectError) as exc_info: - if stream: - for _ in client.make_similar_hats(Size(inches=10)): - pass - else: - client.make_hat(Size(inches=10)) + await call(client, method, Size(inches=10)) assert exc_info.value.code == Code.UNIMPLEMENTED assert ( exc_info.value.message @@ -206,45 +170,11 @@ def decompress( @pytest.mark.asyncio -@pytest.mark.parametrize("stream", _streams) -async def test_mixed_case_request_compression_async(stream: bool) -> None: - class SimpleHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="blue") - - async def make_similar_hats(self, request, _ctx): - yield Hat(size=request.inches, color="blue") - - app = HaberdasherASGIApplication( - SimpleHaberdasher(), compressions=[_XorCompression()] - ) - client = haberdasher_client(ASGITransport(app), send_compression=_XorCompression()) - if stream: - hats = [hat async for hat in client.make_similar_hats(Size(inches=10))] - else: - hats = [await client.make_hat(Size(inches=10))] - assert hats == [Hat(size=10, color="blue")] - - -@pytest.mark.parametrize("stream", _streams) -def test_mixed_case_request_compression_sync(stream: bool) -> None: - class SimpleHaberdasher(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="blue") - - def make_similar_hats(self, request, _ctx): - yield Hat(size=request.inches, color="blue") - - app = HaberdasherWSGIApplication( - SimpleHaberdasher(), compressions=[_XorCompression()] - ) - client = haberdasher_client_sync( - WSGITransport(app), send_compression=_XorCompression() - ) - if stream: - hats = list(client.make_similar_hats(Size(inches=10))) - else: - hats = [client.make_hat(Size(inches=10))] +@pytest.mark.parametrize("method", _methods) +async def test_mixed_case_request_compression(new_client, method: str) -> None: + client = new_client([_XorCompression()], send_compression=_XorCompression()) + res = await call(client, method, Size(inches=10)) + hats = res if isinstance(res, list) else [res] assert hats == [Hat(size=10, color="blue")] From c19ad02e48544958e7c922d0e9a70e7ad477ff20 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 20:48:56 -0400 Subject: [PATCH 5/9] Run unary tests once against sync and async clients `unary_client` builds an ASGI or WSGI server whose `MakeHat` calls a plain handler function, so tests whose service only implements `MakeHat` can define it once. Merges the `test_roundtrip`, GET, message-limit, and `test_headers` `_sync`/`_async` pairs; `call` now forwards call options such as `use_get`. Signed-off-by: Stefan VanBuren --- test/_util.py | 42 ++++++++- test/test_client.py | 69 +++----------- test/test_roundtrip.py | 204 +++++++++++------------------------------ 3 files changed, 105 insertions(+), 210 deletions(-) diff --git a/test/_util.py b/test/_util.py index 14a75361..451a1176 100644 --- a/test/_util.py +++ b/test/_util.py @@ -2,9 +2,10 @@ import asyncio from collections.abc import AsyncIterator, Iterator -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal from pyqwest import Client, SyncClient +from pyqwest.testing import ASGITransport, WSGITransport from connectrpc._compression import IdentityCompression from connectrpc.compression.brotli import BrotliCompression @@ -12,14 +13,21 @@ from connectrpc.compression.zstd import ZstdCompression from .connectrpc.example.haberdasher_connect import ( + Haberdasher, + HaberdasherASGIApplication, HaberdasherClient, HaberdasherClientSync, + HaberdasherSync, + HaberdasherWSGIApplication, ) if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from pyqwest import SyncTransport, Transport from connectrpc.compression import Compression + from connectrpc.request import RequestContext from .connectrpc.example.haberdasher_pb import Hat, Size @@ -57,6 +65,7 @@ async def call( client: HaberdasherClient | HaberdasherClientSync, method: str, request: Size | list[Size], + **kwargs, ) -> Hat | list[Hat]: """Calls method on client, passing and returning streams as lists. @@ -66,7 +75,7 @@ async def call( def run() -> Hat | list[Hat]: req = iter(request) if isinstance(request, list) else request - result = getattr(client, method)(req) + result = getattr(client, method)(req, **kwargs) return list(result) if isinstance(result, Iterator) else result return await asyncio.to_thread(run) @@ -76,8 +85,35 @@ async def stream(requests: list[Size]) -> AsyncIterator[Size]: yield r result = getattr(client, method)( - stream(request) if isinstance(request, list) else request + stream(request) if isinstance(request, list) else request, **kwargs ) if isinstance(result, AsyncIterator): return [r async for r in result] return await result + + +def unary_client( + mode: Literal["async", "sync"], + make_hat: Callable[[Size, RequestContext], Hat], + app_options: Mapping[str, Any] | None = None, + **kwargs, +) -> HaberdasherClient | HaberdasherClientSync: + """Returns a client of an ASGI or WSGI server whose MakeHat calls make_hat. + + app_options are passed to the application and kwargs to the client. + """ + if mode == "async": + + class UnaryHaberdasher(Haberdasher): + async def make_hat(self, request, ctx): + return make_hat(request, ctx) + + app = HaberdasherASGIApplication(UnaryHaberdasher(), **(app_options or {})) + return haberdasher_client(ASGITransport(app), **kwargs) + + class UnaryHaberdasherSync(HaberdasherSync): + def make_hat(self, request, ctx): + return make_hat(request, ctx) + + app = HaberdasherWSGIApplication(UnaryHaberdasherSync(), **(app_options or {})) + return haberdasher_client_sync(WSGITransport(app), **kwargs) diff --git a/test/test_client.py b/test/test_client.py index 4e07e49d..b5e07e6f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -16,7 +16,7 @@ from connectrpc.client import ResponseMetadata from connectrpc.protocol import ProtocolType -from ._util import haberdasher_client, haberdasher_client_sync +from ._util import call, haberdasher_client, haberdasher_client_sync, unary_client from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -58,73 +58,28 @@ ] -@pytest.mark.parametrize( - ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases -) -def test_headers_sync(headers, trailers, response_headers, response_trailers) -> None: - class HeadersHaberdasherSync(HaberdasherSync): - def __init__( - self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] - ) -> None: - self.headers = headers - self.trailers = trailers - - def make_hat(self, _request, ctx): - for key, value in self.headers: - ctx.response_headers.add(key, value) - for key, value in self.trailers: - ctx.response_trailers.add(key, value) - return Hat() - - transport = WSGITransport( - HaberdasherWSGIApplication(HeadersHaberdasherSync(headers, trailers)) - ) - - client = haberdasher_client_sync(transport) - - with ResponseMetadata() as resp: - assert resp.http_status is None - assert list(resp.headers.allitems()) == [] - assert list(resp.trailers.allitems()) == [] - client.make_hat(Size(inches=10)) - - assert resp.http_status == 200 - assert list(resp.headers.allitems()) == response_headers - assert list(resp.trailers.allitems()) == response_trailers - - @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize( ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases ) -async def test_headers_async( - headers, trailers, response_headers, response_trailers +async def test_headers( + mode, headers, trailers, response_headers, response_trailers ) -> None: - class HeadersHaberdasher(Haberdasher): - def __init__( - self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] - ) -> None: - self.headers = headers - self.trailers = trailers - - async def make_hat(self, _request, ctx): - for key, value in self.headers: - ctx.response_headers.add(key, value) - for key, value in self.trailers: - ctx.response_trailers.add(key, value) - return Hat() - - transport = ASGITransport( - HaberdasherASGIApplication(HeadersHaberdasher(headers, trailers)) - ) + def make_hat(_request, ctx): + for key, value in headers: + ctx.response_headers.add(key, value) + for key, value in trailers: + ctx.response_trailers.add(key, value) + return Hat() - client = haberdasher_client(transport) + client = unary_client(mode, make_hat) with ResponseMetadata() as resp: assert resp.http_status is None assert list(resp.headers.allitems()) == [] assert list(resp.trailers.allitems()) == [] - await client.make_hat(Size(inches=10)) + await call(client, "make_hat", Size(inches=10)) assert resp.http_status == 200 assert list(resp.headers.allitems()) == response_headers diff --git a/test/test_roundtrip.py b/test/test_roundtrip.py index 65b7b44f..3cc9d271 100644 --- a/test/test_roundtrip.py +++ b/test/test_roundtrip.py @@ -4,7 +4,7 @@ import random import struct import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn import pytest from pyqwest import Client, SyncClient @@ -15,7 +15,13 @@ from connectrpc.errors import ConnectError from connectrpc.server import DEFAULT_READ_MAX_BYTES -from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression +from ._util import ( + call, + haberdasher_client, + haberdasher_client_sync, + resolve_compression, + unary_client, +) from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -30,48 +36,22 @@ from asgiref.typing import HTTPDisconnectEvent, HTTPRequestEvent, HTTPScope +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("proto_json", [False, True]) @pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) -def test_roundtrip_sync(proto_json: bool, compression_name: str) -> None: - class RoundtripHaberdasherSync(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - - compression = resolve_compression(compression_name) - app = HaberdasherWSGIApplication( - RoundtripHaberdasherSync(), compressions=[compression] - ) - with haberdasher_client_sync( - WSGITransport(app=app), - codec=proto_json_codec() if proto_json else None, - send_compression=compression, - accept_compression=[compression], - ) as client: - response = client.make_hat(request=Size(inches=10)) - assert response.size == 10 - assert response.color == "green" - - -@pytest.mark.parametrize("proto_json", [False, True]) -@pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) -@pytest.mark.asyncio -async def test_roundtrip_async(proto_json: bool, compression_name: str) -> None: - class DetailsHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - +async def test_roundtrip(mode, proto_json: bool, compression_name: str) -> None: compression = resolve_compression(compression_name) - app = HaberdasherASGIApplication(DetailsHaberdasher(), compressions=[compression]) - transport = ASGITransport(app) - async with haberdasher_client( - transport, + client = unary_client( + mode, + lambda request, _ctx: Hat(size=request.inches, color="green"), + {"compressions": [compression]}, codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], - ) as client: - response = await client.make_hat(request=Size(inches=10)) - assert response.size == 10 - assert response.color == "green" + ) + response = await call(client, "make_hat", Size(inches=10)) + assert response == Hat(size=10, color="green") # A request and a response containing a field the receiver's schema doesn't have, @@ -209,40 +189,19 @@ async def app(_scope, _receive, send): await client.make_hat(request=Size(inches=10)) -def test_roundtrip_sync_connect_get_empty_request() -> None: - class RoundtripHaberdasherSync(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) +async def test_roundtrip_connect_get_empty_request(mode) -> None: compression = resolve_compression("identity") - app = HaberdasherWSGIApplication( - RoundtripHaberdasherSync(), compressions=[compression] - ) - with haberdasher_client_sync( - WSGITransport(app=app), + client = unary_client( + mode, + lambda request, _ctx: Hat(size=request.inches, color="green"), + {"compressions": [compression]}, send_compression=compression, accept_compression=[compression], - ) as client: - response = client.make_hat(request=Size(), use_get=True) - assert response.size == 0 - assert response.color == "green" - - -@pytest.mark.asyncio -async def test_roundtrip_async_connect_get_empty_request() -> None: - class RoundtripHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - - compression = resolve_compression("identity") - app = HaberdasherASGIApplication(RoundtripHaberdasher(), compressions=[compression]) - transport = ASGITransport(app) - async with haberdasher_client( - transport, send_compression=compression, accept_compression=[compression] - ) as client: - response = await client.make_hat(request=Size(), use_get=True) - assert response.size == 0 - assert response.color == "green" + ) + response = await call(client, "make_hat", Size(), use_get=True) + assert response == Hat(size=0, color="green") @pytest.mark.parametrize("proto_json", [False, True]) @@ -454,41 +413,18 @@ async def request_stream(): assert len(responses) == 1 -@pytest.mark.parametrize("large", [False, True]) -def test_message_limit_unary_error_sync(large: bool) -> None: - message = "x" * 100 if large else "small" - - class FailingHaberdasher(HaberdasherSync): - def make_hat(self, _request, _ctx): - raise ConnectError(Code.FAILED_PRECONDITION, message) - - app = HaberdasherWSGIApplication(FailingHaberdasher()) - with ( - haberdasher_client_sync(WSGITransport(app), read_max_bytes=100) as client, - pytest.raises(ConnectError) as exc_info, - ): - client.make_hat(request=Size()) - if large: - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert exc_info.value.message == "message is larger than configured max 100" - else: - assert exc_info.value.code == Code.FAILED_PRECONDITION - assert exc_info.value.message == message - - -@pytest.mark.parametrize("large", [False, True]) @pytest.mark.asyncio -async def test_message_limit_unary_error_async(large: bool) -> None: +@pytest.mark.parametrize("mode", ["async", "sync"]) +@pytest.mark.parametrize("large", [False, True]) +async def test_message_limit_unary_error(mode, large: bool) -> None: message = "x" * 100 if large else "small" - class FailingHaberdasher(Haberdasher): - async def make_hat(self, _request, _ctx): - raise ConnectError(Code.FAILED_PRECONDITION, message) + def make_hat(_request, _ctx) -> NoReturn: + raise ConnectError(Code.FAILED_PRECONDITION, message) - app = HaberdasherASGIApplication(FailingHaberdasher()) - async with haberdasher_client(ASGITransport(app), read_max_bytes=100) as client: - with pytest.raises(ConnectError) as exc_info: - await client.make_hat(request=Size()) + client = unary_client(mode, make_hat, read_max_bytes=100) + with pytest.raises(ConnectError) as exc_info: + await call(client, "make_hat", Size()) if large: assert exc_info.value.code == Code.RESOURCE_EXHAUSTED assert exc_info.value.message == "message is larger than configured max 100" @@ -501,61 +437,29 @@ async def make_hat(self, _request, _ctx): _BIG_DESCRIPTION_LENGTH = DEFAULT_READ_MAX_BYTES + 1 - 5 -@pytest.mark.parametrize("unlimited", [False, True]) -def test_message_limit_default_sync(unlimited: bool) -> None: - class EchoSizeHaberdasher(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=len(request.description)) - - big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) - assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 - # We specifically want to test not setting vs setting to None - app = ( - HaberdasherWSGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) - if unlimited - else HaberdasherWSGIApplication(EchoSizeHaberdasher()) - ) - with haberdasher_client_sync(WSGITransport(app)) as client: - if unlimited: - response = client.make_hat(request=big_size) - assert response.size == _BIG_DESCRIPTION_LENGTH - else: - with pytest.raises(ConnectError) as exc_info: - client.make_hat(request=big_size) - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert ( - exc_info.value.message - == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" - ) - - -@pytest.mark.parametrize("unlimited", [False, True]) @pytest.mark.asyncio -async def test_message_limit_default_async(unlimited: bool) -> None: - class EchoSizeHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=len(request.description)) - +@pytest.mark.parametrize("mode", ["async", "sync"]) +@pytest.mark.parametrize("unlimited", [False, True]) +async def test_message_limit_default(mode, unlimited: bool) -> None: big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 # We specifically want to test not setting vs setting to None - app = ( - HaberdasherASGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) - if unlimited - else HaberdasherASGIApplication(EchoSizeHaberdasher()) + client = unary_client( + mode, + lambda request, _ctx: Hat(size=len(request.description)), + {"read_max_bytes": None} if unlimited else None, ) - async with haberdasher_client(ASGITransport(app)) as client: - if unlimited: - response = await client.make_hat(request=big_size) - assert response.size == _BIG_DESCRIPTION_LENGTH - else: - with pytest.raises(ConnectError) as exc_info: - await client.make_hat(request=big_size) - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert ( - exc_info.value.message - == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" - ) + if unlimited: + response = await call(client, "make_hat", big_size) + assert response == Hat(size=_BIG_DESCRIPTION_LENGTH) + else: + with pytest.raises(ConnectError) as exc_info: + await call(client, "make_hat", big_size) + assert exc_info.value.code == Code.RESOURCE_EXHAUSTED + assert ( + exc_info.value.message + == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" + ) @pytest.mark.asyncio From 983e285fceae26c0deb45680437c60eb5f17431b Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 21:40:56 -0400 Subject: [PATCH 6/9] Drop `unary_client` and restore the tests it merged Its handler-function parameter and untyped `app_options`/`**kwargs` lose type checking and IDE completion for application and client options, which outweighs the duplication it removed. Reverts c19ad02e4854. Signed-off-by: Stefan VanBuren --- test/_util.py | 42 +-------- test/test_client.py | 69 +++++++++++--- test/test_roundtrip.py | 204 ++++++++++++++++++++++++++++++----------- 3 files changed, 210 insertions(+), 105 deletions(-) diff --git a/test/_util.py b/test/_util.py index 451a1176..14a75361 100644 --- a/test/_util.py +++ b/test/_util.py @@ -2,10 +2,9 @@ import asyncio from collections.abc import AsyncIterator, Iterator -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING from pyqwest import Client, SyncClient -from pyqwest.testing import ASGITransport, WSGITransport from connectrpc._compression import IdentityCompression from connectrpc.compression.brotli import BrotliCompression @@ -13,21 +12,14 @@ from connectrpc.compression.zstd import ZstdCompression from .connectrpc.example.haberdasher_connect import ( - Haberdasher, - HaberdasherASGIApplication, HaberdasherClient, HaberdasherClientSync, - HaberdasherSync, - HaberdasherWSGIApplication, ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping - from pyqwest import SyncTransport, Transport from connectrpc.compression import Compression - from connectrpc.request import RequestContext from .connectrpc.example.haberdasher_pb import Hat, Size @@ -65,7 +57,6 @@ async def call( client: HaberdasherClient | HaberdasherClientSync, method: str, request: Size | list[Size], - **kwargs, ) -> Hat | list[Hat]: """Calls method on client, passing and returning streams as lists. @@ -75,7 +66,7 @@ async def call( def run() -> Hat | list[Hat]: req = iter(request) if isinstance(request, list) else request - result = getattr(client, method)(req, **kwargs) + result = getattr(client, method)(req) return list(result) if isinstance(result, Iterator) else result return await asyncio.to_thread(run) @@ -85,35 +76,8 @@ async def stream(requests: list[Size]) -> AsyncIterator[Size]: yield r result = getattr(client, method)( - stream(request) if isinstance(request, list) else request, **kwargs + stream(request) if isinstance(request, list) else request ) if isinstance(result, AsyncIterator): return [r async for r in result] return await result - - -def unary_client( - mode: Literal["async", "sync"], - make_hat: Callable[[Size, RequestContext], Hat], - app_options: Mapping[str, Any] | None = None, - **kwargs, -) -> HaberdasherClient | HaberdasherClientSync: - """Returns a client of an ASGI or WSGI server whose MakeHat calls make_hat. - - app_options are passed to the application and kwargs to the client. - """ - if mode == "async": - - class UnaryHaberdasher(Haberdasher): - async def make_hat(self, request, ctx): - return make_hat(request, ctx) - - app = HaberdasherASGIApplication(UnaryHaberdasher(), **(app_options or {})) - return haberdasher_client(ASGITransport(app), **kwargs) - - class UnaryHaberdasherSync(HaberdasherSync): - def make_hat(self, request, ctx): - return make_hat(request, ctx) - - app = HaberdasherWSGIApplication(UnaryHaberdasherSync(), **(app_options or {})) - return haberdasher_client_sync(WSGITransport(app), **kwargs) diff --git a/test/test_client.py b/test/test_client.py index b5e07e6f..4e07e49d 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -16,7 +16,7 @@ from connectrpc.client import ResponseMetadata from connectrpc.protocol import ProtocolType -from ._util import call, haberdasher_client, haberdasher_client_sync, unary_client +from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -58,28 +58,73 @@ ] +@pytest.mark.parametrize( + ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases +) +def test_headers_sync(headers, trailers, response_headers, response_trailers) -> None: + class HeadersHaberdasherSync(HaberdasherSync): + def __init__( + self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] + ) -> None: + self.headers = headers + self.trailers = trailers + + def make_hat(self, _request, ctx): + for key, value in self.headers: + ctx.response_headers.add(key, value) + for key, value in self.trailers: + ctx.response_trailers.add(key, value) + return Hat() + + transport = WSGITransport( + HaberdasherWSGIApplication(HeadersHaberdasherSync(headers, trailers)) + ) + + client = haberdasher_client_sync(transport) + + with ResponseMetadata() as resp: + assert resp.http_status is None + assert list(resp.headers.allitems()) == [] + assert list(resp.trailers.allitems()) == [] + client.make_hat(Size(inches=10)) + + assert resp.http_status == 200 + assert list(resp.headers.allitems()) == response_headers + assert list(resp.trailers.allitems()) == response_trailers + + @pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize( ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases ) -async def test_headers( - mode, headers, trailers, response_headers, response_trailers +async def test_headers_async( + headers, trailers, response_headers, response_trailers ) -> None: - def make_hat(_request, ctx): - for key, value in headers: - ctx.response_headers.add(key, value) - for key, value in trailers: - ctx.response_trailers.add(key, value) - return Hat() + class HeadersHaberdasher(Haberdasher): + def __init__( + self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] + ) -> None: + self.headers = headers + self.trailers = trailers + + async def make_hat(self, _request, ctx): + for key, value in self.headers: + ctx.response_headers.add(key, value) + for key, value in self.trailers: + ctx.response_trailers.add(key, value) + return Hat() + + transport = ASGITransport( + HaberdasherASGIApplication(HeadersHaberdasher(headers, trailers)) + ) - client = unary_client(mode, make_hat) + client = haberdasher_client(transport) with ResponseMetadata() as resp: assert resp.http_status is None assert list(resp.headers.allitems()) == [] assert list(resp.trailers.allitems()) == [] - await call(client, "make_hat", Size(inches=10)) + await client.make_hat(Size(inches=10)) assert resp.http_status == 200 assert list(resp.headers.allitems()) == response_headers diff --git a/test/test_roundtrip.py b/test/test_roundtrip.py index 3cc9d271..65b7b44f 100644 --- a/test/test_roundtrip.py +++ b/test/test_roundtrip.py @@ -4,7 +4,7 @@ import random import struct import sys -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING import pytest from pyqwest import Client, SyncClient @@ -15,13 +15,7 @@ from connectrpc.errors import ConnectError from connectrpc.server import DEFAULT_READ_MAX_BYTES -from ._util import ( - call, - haberdasher_client, - haberdasher_client_sync, - resolve_compression, - unary_client, -) +from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -36,22 +30,48 @@ from asgiref.typing import HTTPDisconnectEvent, HTTPRequestEvent, HTTPScope -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("proto_json", [False, True]) @pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) -async def test_roundtrip(mode, proto_json: bool, compression_name: str) -> None: +def test_roundtrip_sync(proto_json: bool, compression_name: str) -> None: + class RoundtripHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + compression = resolve_compression(compression_name) - client = unary_client( - mode, - lambda request, _ctx: Hat(size=request.inches, color="green"), - {"compressions": [compression]}, + app = HaberdasherWSGIApplication( + RoundtripHaberdasherSync(), compressions=[compression] + ) + with haberdasher_client_sync( + WSGITransport(app=app), codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], - ) - response = await call(client, "make_hat", Size(inches=10)) - assert response == Hat(size=10, color="green") + ) as client: + response = client.make_hat(request=Size(inches=10)) + assert response.size == 10 + assert response.color == "green" + + +@pytest.mark.parametrize("proto_json", [False, True]) +@pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) +@pytest.mark.asyncio +async def test_roundtrip_async(proto_json: bool, compression_name: str) -> None: + class DetailsHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + + compression = resolve_compression(compression_name) + app = HaberdasherASGIApplication(DetailsHaberdasher(), compressions=[compression]) + transport = ASGITransport(app) + async with haberdasher_client( + transport, + codec=proto_json_codec() if proto_json else None, + send_compression=compression, + accept_compression=[compression], + ) as client: + response = await client.make_hat(request=Size(inches=10)) + assert response.size == 10 + assert response.color == "green" # A request and a response containing a field the receiver's schema doesn't have, @@ -189,19 +209,40 @@ async def app(_scope, _receive, send): await client.make_hat(request=Size(inches=10)) -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["async", "sync"]) -async def test_roundtrip_connect_get_empty_request(mode) -> None: +def test_roundtrip_sync_connect_get_empty_request() -> None: + class RoundtripHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + compression = resolve_compression("identity") - client = unary_client( - mode, - lambda request, _ctx: Hat(size=request.inches, color="green"), - {"compressions": [compression]}, + app = HaberdasherWSGIApplication( + RoundtripHaberdasherSync(), compressions=[compression] + ) + with haberdasher_client_sync( + WSGITransport(app=app), send_compression=compression, accept_compression=[compression], - ) - response = await call(client, "make_hat", Size(), use_get=True) - assert response == Hat(size=0, color="green") + ) as client: + response = client.make_hat(request=Size(), use_get=True) + assert response.size == 0 + assert response.color == "green" + + +@pytest.mark.asyncio +async def test_roundtrip_async_connect_get_empty_request() -> None: + class RoundtripHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + + compression = resolve_compression("identity") + app = HaberdasherASGIApplication(RoundtripHaberdasher(), compressions=[compression]) + transport = ASGITransport(app) + async with haberdasher_client( + transport, send_compression=compression, accept_compression=[compression] + ) as client: + response = await client.make_hat(request=Size(), use_get=True) + assert response.size == 0 + assert response.color == "green" @pytest.mark.parametrize("proto_json", [False, True]) @@ -413,18 +454,41 @@ async def request_stream(): assert len(responses) == 1 -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("large", [False, True]) -async def test_message_limit_unary_error(mode, large: bool) -> None: +def test_message_limit_unary_error_sync(large: bool) -> None: + message = "x" * 100 if large else "small" + + class FailingHaberdasher(HaberdasherSync): + def make_hat(self, _request, _ctx): + raise ConnectError(Code.FAILED_PRECONDITION, message) + + app = HaberdasherWSGIApplication(FailingHaberdasher()) + with ( + haberdasher_client_sync(WSGITransport(app), read_max_bytes=100) as client, + pytest.raises(ConnectError) as exc_info, + ): + client.make_hat(request=Size()) + if large: + assert exc_info.value.code == Code.RESOURCE_EXHAUSTED + assert exc_info.value.message == "message is larger than configured max 100" + else: + assert exc_info.value.code == Code.FAILED_PRECONDITION + assert exc_info.value.message == message + + +@pytest.mark.parametrize("large", [False, True]) +@pytest.mark.asyncio +async def test_message_limit_unary_error_async(large: bool) -> None: message = "x" * 100 if large else "small" - def make_hat(_request, _ctx) -> NoReturn: - raise ConnectError(Code.FAILED_PRECONDITION, message) + class FailingHaberdasher(Haberdasher): + async def make_hat(self, _request, _ctx): + raise ConnectError(Code.FAILED_PRECONDITION, message) - client = unary_client(mode, make_hat, read_max_bytes=100) - with pytest.raises(ConnectError) as exc_info: - await call(client, "make_hat", Size()) + app = HaberdasherASGIApplication(FailingHaberdasher()) + async with haberdasher_client(ASGITransport(app), read_max_bytes=100) as client: + with pytest.raises(ConnectError) as exc_info: + await client.make_hat(request=Size()) if large: assert exc_info.value.code == Code.RESOURCE_EXHAUSTED assert exc_info.value.message == "message is larger than configured max 100" @@ -437,29 +501,61 @@ def make_hat(_request, _ctx) -> NoReturn: _BIG_DESCRIPTION_LENGTH = DEFAULT_READ_MAX_BYTES + 1 - 5 -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("unlimited", [False, True]) -async def test_message_limit_default(mode, unlimited: bool) -> None: +def test_message_limit_default_sync(unlimited: bool) -> None: + class EchoSizeHaberdasher(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=len(request.description)) + big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 # We specifically want to test not setting vs setting to None - client = unary_client( - mode, - lambda request, _ctx: Hat(size=len(request.description)), - {"read_max_bytes": None} if unlimited else None, + app = ( + HaberdasherWSGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) + if unlimited + else HaberdasherWSGIApplication(EchoSizeHaberdasher()) ) - if unlimited: - response = await call(client, "make_hat", big_size) - assert response == Hat(size=_BIG_DESCRIPTION_LENGTH) - else: - with pytest.raises(ConnectError) as exc_info: - await call(client, "make_hat", big_size) - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert ( - exc_info.value.message - == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" - ) + with haberdasher_client_sync(WSGITransport(app)) as client: + if unlimited: + response = client.make_hat(request=big_size) + assert response.size == _BIG_DESCRIPTION_LENGTH + else: + with pytest.raises(ConnectError) as exc_info: + client.make_hat(request=big_size) + assert exc_info.value.code == Code.RESOURCE_EXHAUSTED + assert ( + exc_info.value.message + == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" + ) + + +@pytest.mark.parametrize("unlimited", [False, True]) +@pytest.mark.asyncio +async def test_message_limit_default_async(unlimited: bool) -> None: + class EchoSizeHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=len(request.description)) + + big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) + assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 + # We specifically want to test not setting vs setting to None + app = ( + HaberdasherASGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) + if unlimited + else HaberdasherASGIApplication(EchoSizeHaberdasher()) + ) + async with haberdasher_client(ASGITransport(app)) as client: + if unlimited: + response = await client.make_hat(request=big_size) + assert response.size == _BIG_DESCRIPTION_LENGTH + else: + with pytest.raises(ConnectError) as exc_info: + await client.make_hat(request=big_size) + assert exc_info.value.code == Code.RESOURCE_EXHAUSTED + assert ( + exc_info.value.message + == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" + ) @pytest.mark.asyncio From d2cfce5f78f2438513f0009a90f2b6fded4e643d Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 21:41:30 -0400 Subject: [PATCH 7/9] Pass bound client methods to `call` `call` dispatches on the method's client instead of taking the client and a method name, and no longer forwards call options as untyped `**kwargs`. Signed-off-by: Stefan VanBuren --- test/_util.py | 22 +++++++--------------- test/test_compression.py | 6 +++--- test/test_interceptor.py | 4 ++-- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/test/_util.py b/test/_util.py index 14a75361..4e0ed82d 100644 --- a/test/_util.py +++ b/test/_util.py @@ -17,6 +17,8 @@ ) if TYPE_CHECKING: + from types import MethodType + from pyqwest import SyncTransport, Transport from connectrpc.compression import Compression @@ -53,20 +55,12 @@ def haberdasher_client_sync( ) -async def call( - client: HaberdasherClient | HaberdasherClientSync, - method: str, - request: Size | list[Size], -) -> Hat | list[Hat]: - """Calls method on client, passing and returning streams as lists. - - A sync client runs in a worker thread, as it would in a sync program. - """ - if isinstance(client, HaberdasherClientSync): +async def call(method: MethodType, request: Size | list[Size]) -> Hat | list[Hat]: + """Calls a client method, passing and returning streams as lists.""" + if isinstance(method.__self__, HaberdasherClientSync): def run() -> Hat | list[Hat]: - req = iter(request) if isinstance(request, list) else request - result = getattr(client, method)(req) + result = method(iter(request) if isinstance(request, list) else request) return list(result) if isinstance(result, Iterator) else result return await asyncio.to_thread(run) @@ -75,9 +69,7 @@ async def stream(requests: list[Size]) -> AsyncIterator[Size]: for r in requests: yield r - result = getattr(client, method)( - stream(request) if isinstance(request, list) else request - ) + result = method(stream(request) if isinstance(request, list) else request) if isinstance(result, AsyncIterator): return [r async for r in result] return await result diff --git a/test/test_compression.py b/test/test_compression.py index 60116aa3..8c1d7f16 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -103,7 +103,7 @@ async def test_server_compressions( send_compression=None, ) with ResponseMetadata() as meta: - res = await call(client, "make_hat", Size(inches=10)) + res = await call(client.make_hat, Size(inches=10)) assert res == Hat(size=10, color="blue") assert meta.headers.get("content-encoding") == encoding @@ -124,7 +124,7 @@ async def test_unknown_request_compression( # The server only supports the default gzip. client = new_client(protocol=protocol, send_compression=ZstdCompression()) with pytest.raises(ConnectError) as exc_info: - await call(client, method, Size(inches=10)) + await call(getattr(client, method), Size(inches=10)) assert exc_info.value.code == Code.UNIMPLEMENTED assert ( exc_info.value.message @@ -173,7 +173,7 @@ def decompress( @pytest.mark.parametrize("method", _methods) async def test_mixed_case_request_compression(new_client, method: str) -> None: client = new_client([_XorCompression()], send_compression=_XorCompression()) - res = await call(client, method, Size(inches=10)) + res = await call(getattr(client, method), Size(inches=10)) hats = res if isinstance(res, list) else [res] assert hats == [Hat(size=10, color="blue")] diff --git a/test/test_interceptor.py b/test/test_interceptor.py index ec658c2e..c2172fc6 100644 --- a/test/test_interceptor.py +++ b/test/test_interceptor.py @@ -192,7 +192,7 @@ async def test_intercept( expected: Hat | list[Hat], name: str, ) -> None: - assert await call(client, method, request_) == expected + assert await call(getattr(client, method), request_) == expected assert client_interceptor.result == [f"Hello {name} and goodbye"] assert server_interceptor.result == [f"Hello {name} and goodbye"] @@ -228,7 +228,7 @@ async def test_intercept_error( name: str, ) -> None: with pytest.raises(ConnectError): - await call(client, method, request_) + await call(getattr(client, method), request_) expected = f"Hello {name} and goodbye with error Size must be non-negative" assert client_interceptor.result == [expected] assert server_interceptor.result == [expected] From 9a8dee8745e3aee402bf58841ad6821814fb1f37 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 8 Oct 2026 21:43:11 -0400 Subject: [PATCH 8/9] Drop the Haberdasher client helpers `haberdasher_client` and `haberdasher_client_sync` forwarded client options as untyped `**kwargs`, losing type checking and IDE completion at every call site for a few lines of boilerplate. Tests construct clients directly again, and files the helpers only touched match `main`. The compression tests' `new_client` factory takes the client options it uses as typed keyword arguments. Signed-off-by: Stefan VanBuren --- test/_util.py | 23 +----------- test/test_client.py | 21 ++++++++--- test/test_codec.py | 19 +++++++--- test/test_compression.py | 29 ++++++++++----- test/test_details.py | 12 +++++-- test/test_errors.py | 76 +++++++++++++++++++++++++++++----------- test/test_http.py | 15 ++++++-- test/test_interceptor.py | 30 +++++++++++----- test/test_lifespan.py | 14 +++++--- test/test_roundtrip.py | 75 ++++++++++++++++++++++++++------------- 10 files changed, 211 insertions(+), 103 deletions(-) diff --git a/test/_util.py b/test/_util.py index 4e0ed82d..a5575bea 100644 --- a/test/_util.py +++ b/test/_util.py @@ -4,23 +4,16 @@ from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING -from pyqwest import Client, SyncClient - from connectrpc._compression import IdentityCompression from connectrpc.compression.brotli import BrotliCompression from connectrpc.compression.gzip import GzipCompression from connectrpc.compression.zstd import ZstdCompression -from .connectrpc.example.haberdasher_connect import ( - HaberdasherClient, - HaberdasherClientSync, -) +from .connectrpc.example.haberdasher_connect import HaberdasherClientSync if TYPE_CHECKING: from types import MethodType - from pyqwest import SyncTransport, Transport - from connectrpc.compression import Compression from .connectrpc.example.haberdasher_pb import Hat, Size @@ -41,20 +34,6 @@ def resolve_compression(encoding: str) -> Compression: raise ValueError(msg) -def haberdasher_client(transport: Transport, **kwargs) -> HaberdasherClient: - return HaberdasherClient( - "http://localhost", http_client=Client(transport), **kwargs - ) - - -def haberdasher_client_sync( - transport: SyncTransport, **kwargs -) -> HaberdasherClientSync: - return HaberdasherClientSync( - "http://localhost", http_client=SyncClient(transport), **kwargs - ) - - async def call(method: MethodType, request: Size | list[Size]) -> Hat | list[Hat]: """Calls a client method, passing and returning streams as lists.""" if isinstance(method.__self__, HaberdasherClientSync): diff --git a/test/test_client.py b/test/test_client.py index 4e07e49d..ee6c47ec 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -4,8 +4,10 @@ import pytest from pyqwest import ( + Client, Request, Response, + SyncClient, SyncRequest, SyncResponse, SyncTransport, @@ -16,10 +18,11 @@ from connectrpc.client import ResponseMetadata from connectrpc.protocol import ProtocolType -from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -80,7 +83,9 @@ def make_hat(self, _request, ctx): HaberdasherWSGIApplication(HeadersHaberdasherSync(headers, trailers)) ) - client = haberdasher_client_sync(transport) + client = HaberdasherClientSync( + "http://localhost", http_client=SyncClient(transport=transport) + ) with ResponseMetadata() as resp: assert resp.http_status is None @@ -118,7 +123,9 @@ async def make_hat(self, _request, ctx): HaberdasherASGIApplication(HeadersHaberdasher(headers, trailers)) ) - client = haberdasher_client(transport) + client = HaberdasherClient( + "http://localhost", http_client=Client(transport=transport) + ) with ResponseMetadata() as resp: assert resp.http_status is None @@ -169,7 +176,9 @@ async def sizes() -> AsyncIterator[Size]: transport = ContentRecorder( ASGITransport(HaberdasherASGIApplication(SizeHaberdasher())) ) - async with haberdasher_client(transport, protocol=protocol) as client: + async with HaberdasherClient( + "http://localhost", protocol=protocol, http_client=Client(transport=transport) + ) as client: await client.make_hat(Size(inches=10)) assert [hat async for hat in client.make_similar_hats(Size(inches=10))] await client.make_flexible_hat(sizes()) @@ -212,7 +221,9 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: transport = ContentRecorder( WSGITransport(HaberdasherWSGIApplication(SizeHaberdasherSync())) ) - with haberdasher_client_sync(transport, protocol=protocol) as client: + with HaberdasherClientSync( + "http://localhost", protocol=protocol, http_client=SyncClient(transport) + ) as client: client.make_hat(Size(inches=10)) assert list(client.make_similar_hats(Size(inches=10))) client.make_flexible_hat(iter([Size(inches=10)])) diff --git a/test/test_codec.py b/test/test_codec.py index 59ad7000..bc38e867 100644 --- a/test/test_codec.py +++ b/test/test_codec.py @@ -3,8 +3,10 @@ import pytest from protobuf import Message from pyqwest import ( + Client, Request, Response, + SyncClient, SyncRequest, SyncResponse, SyncTransport, @@ -14,10 +16,11 @@ from connectrpc.codec import Codec -from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -93,7 +96,12 @@ async def execute(self, request: Request) -> Response: HaberdasherASGIApplication(SimpleHaberdasher(), codecs=[CustomCodec()]) ) ) - client = haberdasher_client(transport, codec=CustomCodec(), send_compression=None) + client = HaberdasherClient( + "http://localhost", + http_client=Client(transport), + codec=CustomCodec(), + send_compression=None, + ) res = await client.make_hat(Size(inches=10)) assert res.size == 10 @@ -129,8 +137,11 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: HaberdasherWSGIApplication(SimpleHaberdasherSync(), codecs=[CustomCodec()]) ) ) - client = haberdasher_client_sync( - transport, codec=CustomCodec(), send_compression=None + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(transport=transport), + codec=CustomCodec(), + send_compression=None, ) res = client.make_hat(Size(inches=10)) diff --git a/test/test_compression.py b/test/test_compression.py index 8c1d7f16..cb71755a 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -24,12 +24,7 @@ from connectrpc.protocol import ProtocolType from connectrpc.request import Headers -from ._util import ( - call, - haberdasher_client, - haberdasher_client_sync, - resolve_compression, -) +from ._util import call, resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -68,17 +63,33 @@ def new_client(request: pytest.FixtureRequest): """Returns a factory for clients of a server with the given compressions.""" def new_client( - compressions: Iterable[Compression] | None = None, **kwargs + compressions: Iterable[Compression] | None = None, + *, + send_compression: Compression | None, + accept_compression: Iterable[Compression] | None = None, + protocol: ProtocolType = ProtocolType.CONNECT, ) -> HaberdasherClient | HaberdasherClientSync: if request.param == "async": app = HaberdasherASGIApplication( _BlueHaberdasher(), compressions=compressions ) - return haberdasher_client(ASGITransport(app), **kwargs) + return HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), + send_compression=send_compression, + accept_compression=accept_compression, + protocol=protocol, + ) app = HaberdasherWSGIApplication( _BlueHaberdasherSync(), compressions=compressions ) - return haberdasher_client_sync(WSGITransport(app), **kwargs) + return HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + send_compression=send_compression, + accept_compression=accept_compression, + protocol=protocol, + ) return new_client diff --git a/test/test_details.py b/test/test_details.py index caaba258..fcd1ae18 100644 --- a/test/test_details.py +++ b/test/test_details.py @@ -5,16 +5,18 @@ import pytest from protobuf import Oneof from protobuf.wkt import Any as AnyPb, Duration, Struct, Value +from pyqwest import Client, SyncClient from pyqwest.testing import ASGITransport, WSGITransport from connectrpc._protocol import ConnectWireError from connectrpc.code import Code from connectrpc.errors import ConnectError, ErrorDetail -from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -42,7 +44,9 @@ def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(DetailsHaberdasherSync()) transport = WSGITransport(app) with ( - haberdasher_client_sync(transport) as client, + HaberdasherClientSync( + "http://localhost", http_client=SyncClient(transport) + ) as client, pytest.raises(ConnectError) as exc_info, ): client.make_hat(request=Size(inches=10)) @@ -79,7 +83,9 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(DetailsHaberdasher()) transport = ASGITransport(app) - async with haberdasher_client(transport) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(transport=transport) + ) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size(inches=10)) assert exc_info.value.code == Code.RESOURCE_EXHAUSTED diff --git a/test/test_errors.py b/test/test_errors.py index 75c9b6a2..56a2fe20 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -26,10 +26,11 @@ from connectrpc.code import Code from connectrpc.errors import ConnectError -from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -85,8 +86,10 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: recorded_response = response return response + http_client = SyncClient(transport=ResponseRecorder(transport)) + with ( - haberdasher_client_sync(ResponseRecorder(transport)) as client, + HaberdasherClientSync("http://localhost", http_client=http_client) as client, pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp, ): @@ -125,7 +128,8 @@ async def execute(self, request: Request) -> Response: recorded_response = response return response - async with haberdasher_client(ResponseRecorder(transport)) as client: + http_client = Client(transport=ResponseRecorder(transport)) + async with HaberdasherClient("http://localhost", http_client=http_client) as client: with pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp: await client.make_hat(request=Size(inches=10)) @@ -199,7 +203,9 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: # noqa: ARG002 ) with ( - haberdasher_client_sync(MockTransport()) as client, + HaberdasherClientSync( + "http://localhost", http_client=SyncClient(transport=MockTransport()) + ) as client, pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp, ): @@ -225,7 +231,9 @@ async def execute(self, request: Request) -> Response: # noqa: ARG002 headers=Headers(response_headers), ) - async with haberdasher_client(MockTransport()) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(transport=MockTransport()) + ) as client: with pytest.raises(ConnectError) as exc_info, ResponseMetadata() as resp: await client.make_hat(request=Size(inches=10)) assert exc_info.value.code == code @@ -441,9 +449,10 @@ def make_hat(self, _request, _ctx) -> NoReturn: raise AssertionError(msg) app = HaberdasherWSGIApplication(SleepingHaberdasher()) + http_client = SyncClient(ModifyTimeout(WSGITransport(app))) with ( - haberdasher_client_sync( - ModifyTimeout(WSGITransport(app)), timeout_ms=client_timeout_ms + HaberdasherClientSync( + "http://localhost", timeout_ms=client_timeout_ms, http_client=http_client ) as client, pytest.raises(ConnectError) as exc_info, ): @@ -480,8 +489,10 @@ async def make_hat(self, _request, _ctx) -> NoReturn: raise AssertionError(msg) app = HaberdasherASGIApplication(SleepingHaberdasher()) - async with haberdasher_client( - ModifyTimeout(ASGITransport(app)), timeout_ms=client_timeout_ms + http_client = Client(ModifyTimeout(ASGITransport(app))) + + async with HaberdasherClient( + "http://localhost", timeout_ms=client_timeout_ms, http_client=http_client ) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size(inches=10), timeout_ms=call_timeout_ms) @@ -512,8 +523,10 @@ def make_similar_hats(self, _request, _ctx) -> Iterator[Hat]: app = HaberdasherWSGIApplication(RecordingHaberdasher()) with ( - haberdasher_client_sync( - WSGITransport(app), timeout_ms=client_timeout_ms + HaberdasherClientSync( + "http://localhost", + timeout_ms=client_timeout_ms, + http_client=SyncClient(WSGITransport(app)), ) as client, pytest.raises(ConnectError) as exc_info, ): @@ -552,8 +565,10 @@ async def make_similar_hats(self, _request, _ctx) -> AsyncIterator[Hat]: yield Hat() app = HaberdasherASGIApplication(RecordingHaberdasher()) - async with haberdasher_client( - ASGITransport(app), timeout_ms=client_timeout_ms + async with HaberdasherClient( + "http://localhost", + timeout_ms=client_timeout_ms, + http_client=Client(ASGITransport(app)), ) as client: with pytest.raises(ConnectError) as exc_info: if stream: @@ -580,8 +595,11 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) + http_client = Client(transport) - async with haberdasher_client(transport, timeout_ms=200) as client: + async with HaberdasherClient( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client: with pytest.raises(ConnectError, match="Something went wrong"): await client.make_hat(request=Size(inches=10)) @@ -598,8 +616,11 @@ def make_similar_hats(self, _request: Size, _ctx: RequestContext) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) + http_client = Client(transport) - async with haberdasher_client(transport, timeout_ms=200) as client: + async with HaberdasherClient( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client: with pytest.raises(ConnectError, match="Something went wrong"): async for _ in client.make_similar_hats(request=Size(inches=10)): pass @@ -616,8 +637,11 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) + http_client = Client(transport) - async with haberdasher_client(transport, timeout_ms=200) as client: + async with HaberdasherClient( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client: with pytest.raises(ConnectError, match="We're broken"): await client.make_hat(request=Size(inches=10)) @@ -632,8 +656,11 @@ def make_similar_hats(self, _request: Size, _ctx: RequestContext) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) + http_client = Client(transport) - async with haberdasher_client(transport, timeout_ms=200) as client: + async with HaberdasherClient( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client: with pytest.raises(ConnectError, match="We're broken"): async for _ in client.make_similar_hats(request=Size(inches=10)): pass @@ -649,8 +676,11 @@ async def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherASGIApplication(RaisingHaberdasher()) transport = ASGITransport(app) + http_client = Client(transport) - async with haberdasher_client(transport, timeout_ms=200) as client: + async with HaberdasherClient( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client: with pytest.raises(ConnectError, match="Internal Server Error"): await client.make_hat(request=Size(inches=10)) @@ -665,9 +695,12 @@ def make_hat(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(RaisingHaberdasher()) transport = WSGITransport(app) + http_client = SyncClient(transport) with ( - haberdasher_client_sync(transport, timeout_ms=200) as client, + HaberdasherClientSync( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client, pytest.raises(ConnectError, match="Something went wrong"), ): client.make_hat(request=Size(inches=10)) @@ -686,9 +719,12 @@ def make_similar_hats(self, _request, _ctx) -> NoReturn: app = HaberdasherWSGIApplication(RaisingHaberdasher()) transport = WSGITransport(app) + http_client = SyncClient(transport) with ( - haberdasher_client_sync(transport, timeout_ms=200) as client, + HaberdasherClientSync( + "http://localhost", timeout_ms=200, http_client=http_client + ) as client, pytest.raises(ConnectError, match="Something went wrong"), ): next(client.make_similar_hats(request=Size(inches=10))) diff --git a/test/test_http.py b/test/test_http.py index 757eaa98..f5420f01 100644 --- a/test/test_http.py +++ b/test/test_http.py @@ -20,10 +20,11 @@ from connectrpc.codec import proto_json_codec from connectrpc.errors import ConnectError -from ._util import haberdasher_client, haberdasher_client_sync from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -108,7 +109,11 @@ def execute_sync(self, request: SyncRequest) -> SyncResponse: WSGITransport(HaberdasherWSGIApplication(HeadersHaberdasherSync())) ) - client = haberdasher_client_sync(transport, codec=proto_json_codec()) + client = HaberdasherClientSync( + address="http://localhost", + codec=proto_json_codec(), + http_client=SyncClient(transport=transport), + ) hats = list(client.make_similar_hats(Size(inches=2))) assert hats == [Hat(size=2), Hat(size=3)] @@ -136,7 +141,11 @@ async def execute(self, request: Request) -> Response: ASGITransport(HaberdasherASGIApplication(HeadersHaberdasher())) ) - client = haberdasher_client(transport, codec=proto_json_codec()) + client = HaberdasherClient( + address="http://localhost", + codec=proto_json_codec(), + http_client=Client(transport=transport), + ) hats = [] async for hat in client.make_similar_hats(Size(inches=2)): diff --git a/test/test_interceptor.py b/test/test_interceptor.py index c2172fc6..43b55d36 100644 --- a/test/test_interceptor.py +++ b/test/test_interceptor.py @@ -13,7 +13,7 @@ from connectrpc.code import Code from connectrpc.errors import ConnectError -from ._util import call, haberdasher_client, haberdasher_client_sync +from ._util import call from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -131,16 +131,20 @@ async def client( app = HaberdasherASGIApplication( SimpleHaberdasher(), interceptors=(server_interceptor,) ) - async with haberdasher_client( - ASGITransport(app), interceptors=(client_interceptor,) + async with HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), + interceptors=(client_interceptor,), ) as client: yield client else: app = HaberdasherWSGIApplication( SimpleHaberdasherSync(), interceptors=(server_interceptor,) ) - with haberdasher_client_sync( - WSGITransport(app), interceptors=(client_interceptor,) + with HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + interceptors=(client_interceptor,), ) as client: yield client @@ -411,7 +415,9 @@ async def make_hat(self, request, _ctx): SimpleHaberdasher(), interceptors=(EventMetadataInterceptor(), EventUnaryInterceptor()), ) - async with haberdasher_client(ASGITransport(app)) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(ASGITransport(app)) + ) as client: await client.make_hat(Size(inches=10)) assert events == [ @@ -451,7 +457,9 @@ def make_hat(self, request, _ctx): SimpleHaberdasherSync(), interceptors=(EventMetadataInterceptorSync(), EventUnaryInterceptorSync()), ) - with haberdasher_client_sync(WSGITransport(app)) as client: + with HaberdasherClientSync( + "http://localhost", http_client=SyncClient(WSGITransport(app)) + ) as client: client.make_hat(Size(inches=10)) assert events == [ @@ -564,7 +572,9 @@ async def make_similar_hats(self, request, _ctx): app = HaberdasherASGIApplication( SimpleHaberdasher(), interceptors=(_ResponseMetadataInterceptor(),) ) - async with haberdasher_client(ASGITransport(app)) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(ASGITransport(app)) + ) as client: with ResponseMetadata() as resp: await client.make_hat(Size(inches=10)) assert resp.headers.get("x-interceptor") == "ran" @@ -589,7 +599,9 @@ def make_similar_hats(self, request, _ctx): app = HaberdasherWSGIApplication( SimpleHaberdasherSync(), interceptors=(_ResponseMetadataInterceptor(),) ) - with haberdasher_client_sync(WSGITransport(app)) as client: + with HaberdasherClientSync( + "http://localhost", http_client=SyncClient(WSGITransport(app)) + ) as client: with ResponseMetadata() as resp: client.make_hat(Size(inches=10)) assert resp.headers.get("x-interceptor") == "ran" diff --git a/test/test_lifespan.py b/test/test_lifespan.py index ee43da49..63306651 100644 --- a/test/test_lifespan.py +++ b/test/test_lifespan.py @@ -3,16 +3,17 @@ from collections import Counter import pytest +from pyqwest import Client from pyqwest.testing import ASGITransport from connectrpc.code import Code from connectrpc.errors import ConnectError -from ._util import haberdasher_client from .connectrpc.example.empty_connect import NoMethods, NoMethodsASGIApplication from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, ) from .connectrpc.example.haberdasher_pb import Hat, Size @@ -40,7 +41,10 @@ async def counting_haberdasher(): final_count = counter["requests"] app = HaberdasherASGIApplication(counting_haberdasher()) - async with ASGITransport(app) as transport, haberdasher_client(transport) as client: + async with ( + ASGITransport(app) as transport, + HaberdasherClient("http://localhost", http_client=Client(transport)) as client, + ): for _ in range(5): hat = await client.make_hat(Size(inches=10)) assert hat.size == 10 @@ -62,7 +66,7 @@ async def no_methods(): ASGITransport(app) as transport, # No method to invoke, so we just send an unrelated request and make sure # the error code matches expected. - haberdasher_client(transport) as client, + HaberdasherClient("http://localhost", http_client=Client(transport)) as client, ): with pytest.raises(ConnectError) as exc_info: await client.make_hat(Size(inches=10)) @@ -131,7 +135,9 @@ async def counting_haberdasher(): app = HaberdasherASGIApplication(counting_haberdasher()) transport = ASGITransport(app) - async with haberdasher_client(transport) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(transport) + ) as client: with pytest.raises(ConnectError): await client.make_hat(Size(inches=10)) assert ( diff --git a/test/test_roundtrip.py b/test/test_roundtrip.py index 65b7b44f..9a09820b 100644 --- a/test/test_roundtrip.py +++ b/test/test_roundtrip.py @@ -15,10 +15,12 @@ from connectrpc.errors import ConnectError from connectrpc.server import DEFAULT_READ_MAX_BYTES -from ._util import haberdasher_client, haberdasher_client_sync, resolve_compression +from ._util import resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, + HaberdasherClient, + HaberdasherClientSync, HaberdasherSync, HaberdasherWSGIApplication, ) @@ -41,8 +43,9 @@ def make_hat(self, request, _ctx): app = HaberdasherWSGIApplication( RoundtripHaberdasherSync(), compressions=[compression] ) - with haberdasher_client_sync( - WSGITransport(app=app), + with HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app=app)), codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -63,8 +66,9 @@ async def make_hat(self, request, _ctx): compression = resolve_compression(compression_name) app = HaberdasherASGIApplication(DetailsHaberdasher(), compressions=[compression]) transport = ASGITransport(app) - async with haberdasher_client( - transport, + async with HaberdasherClient( + "http://localhost", + http_client=Client(transport), codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -165,8 +169,9 @@ def app(_environ, start_response): ) return [_UNKNOWN_FIELD_RESPONSE] - with haberdasher_client_sync( - WSGITransport(app=app), + with HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app=app)), codec=proto_json_codec(ignore_unknown_fields=ignore_unknown_fields), ) as client: if ignore_unknown_fields: @@ -196,8 +201,9 @@ async def app(_scope, _receive, send): ) await send({"type": "http.response.body", "body": _UNKNOWN_FIELD_RESPONSE}) - async with haberdasher_client( - ASGITransport(app), + async with HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), codec=proto_json_codec(ignore_unknown_fields=ignore_unknown_fields), ) as client: if ignore_unknown_fields: @@ -218,8 +224,9 @@ def make_hat(self, request, _ctx): app = HaberdasherWSGIApplication( RoundtripHaberdasherSync(), compressions=[compression] ) - with haberdasher_client_sync( - WSGITransport(app=app), + with HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app=app)), send_compression=compression, accept_compression=[compression], ) as client: @@ -237,8 +244,11 @@ async def make_hat(self, request, _ctx): compression = resolve_compression("identity") app = HaberdasherASGIApplication(RoundtripHaberdasher(), compressions=[compression]) transport = ASGITransport(app) - async with haberdasher_client( - transport, send_compression=compression, accept_compression=[compression] + async with HaberdasherClient( + "http://localhost", + http_client=Client(transport=transport), + send_compression=compression, + accept_compression=[compression], ) as client: response = await client.make_hat(request=Size(), use_get=True) assert response.size == 0 @@ -263,8 +273,9 @@ async def make_similar_hats(self, request, _ctx): transport = ASGITransport(app) hats: list[Hat] = [] - async with haberdasher_client( - transport, + async with HaberdasherClient( + "http://localhost", + http_client=Client(transport=transport), codec=proto_json_codec() if proto_json else None, send_compression=compression, accept_compression=[compression], @@ -294,7 +305,9 @@ async def make_similar_hats(self, request, _ctx): app = HaberdasherASGIApplication(StreamingHaberdasher()) hats: list[Hat] = [] - async with haberdasher_client(ASGITransport(app)) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(transport=ASGITransport(app)) + ) as client: with pytest.raises(ConnectError) as exc_info: async for h in client.make_similar_hats( request=Size(inches=10), timeout_ms=50 @@ -347,8 +360,9 @@ def make_various_hats(self, request: Iterator[Size], _ctx) -> Iterator[Hat]: LargeHaberdasher(), read_max_bytes=100, compressions=[compression] ) transport = WSGITransport(app) - with haberdasher_client_sync( - transport, + with HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(transport), send_compression=compression, accept_compression=[compression], read_max_bytes=100, @@ -417,8 +431,9 @@ async def make_various_hats( LargeHaberdasher(), read_max_bytes=100, compressions=[compression] ) transport = ASGITransport(app) - async with haberdasher_client( - transport, + async with HaberdasherClient( + "http://localhost", + http_client=Client(transport=transport), send_compression=compression, accept_compression=[compression], read_max_bytes=100, @@ -464,7 +479,11 @@ def make_hat(self, _request, _ctx): app = HaberdasherWSGIApplication(FailingHaberdasher()) with ( - haberdasher_client_sync(WSGITransport(app), read_max_bytes=100) as client, + HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + read_max_bytes=100, + ) as client, pytest.raises(ConnectError) as exc_info, ): client.make_hat(request=Size()) @@ -486,7 +505,11 @@ async def make_hat(self, _request, _ctx): raise ConnectError(Code.FAILED_PRECONDITION, message) app = HaberdasherASGIApplication(FailingHaberdasher()) - async with haberdasher_client(ASGITransport(app), read_max_bytes=100) as client: + async with HaberdasherClient( + "http://localhost", + http_client=Client(transport=ASGITransport(app)), + read_max_bytes=100, + ) as client: with pytest.raises(ConnectError) as exc_info: await client.make_hat(request=Size()) if large: @@ -515,7 +538,9 @@ def make_hat(self, request, _ctx): if unlimited else HaberdasherWSGIApplication(EchoSizeHaberdasher()) ) - with haberdasher_client_sync(WSGITransport(app)) as client: + with HaberdasherClientSync( + "http://localhost", http_client=SyncClient(WSGITransport(app)) + ) as client: if unlimited: response = client.make_hat(request=big_size) assert response.size == _BIG_DESCRIPTION_LENGTH @@ -544,7 +569,9 @@ async def make_hat(self, request, _ctx): if unlimited else HaberdasherASGIApplication(EchoSizeHaberdasher()) ) - async with haberdasher_client(ASGITransport(app)) as client: + async with HaberdasherClient( + "http://localhost", http_client=Client(transport=ASGITransport(app)) + ) as client: if unlimited: response = await client.make_hat(request=big_size) assert response.size == _BIG_DESCRIPTION_LENGTH From 0a1057b1617f189f4ac97eb08581419a9d0394db Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 9 Oct 2026 08:18:59 -0400 Subject: [PATCH 9/9] Run restored unary tests once against sync and async clients Merges the sync and async variants of `test_headers`, `test_roundtrip`, `test_roundtrip_connect_get_empty_request`, `test_message_limit_unary_error`, and `test_message_limit_default` into one test each, parametrized over `mode`. Each test builds its server and client inline under `if mode == "async"`, so application and client options stay typed, and shares the call and assertions through `call`. Signed-off-by: Stefan VanBuren --- test/test_client.py | 98 ++++++-------- test/test_roundtrip.py | 291 +++++++++++++++++++---------------------- 2 files changed, 169 insertions(+), 220 deletions(-) diff --git a/test/test_client.py b/test/test_client.py index ee6c47ec..5f08248f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -18,6 +18,7 @@ from connectrpc.client import ResponseMetadata from connectrpc.protocol import ProtocolType +from ._util import call from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -61,77 +62,52 @@ ] -@pytest.mark.parametrize( - ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases -) -def test_headers_sync(headers, trailers, response_headers, response_trailers) -> None: - class HeadersHaberdasherSync(HaberdasherSync): - def __init__( - self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] - ) -> None: - self.headers = headers - self.trailers = trailers - - def make_hat(self, _request, ctx): - for key, value in self.headers: - ctx.response_headers.add(key, value) - for key, value in self.trailers: - ctx.response_trailers.add(key, value) - return Hat() - - transport = WSGITransport( - HaberdasherWSGIApplication(HeadersHaberdasherSync(headers, trailers)) - ) - - client = HaberdasherClientSync( - "http://localhost", http_client=SyncClient(transport=transport) - ) - - with ResponseMetadata() as resp: - assert resp.http_status is None - assert list(resp.headers.allitems()) == [] - assert list(resp.trailers.allitems()) == [] - client.make_hat(Size(inches=10)) - - assert resp.http_status == 200 - assert list(resp.headers.allitems()) == response_headers - assert list(resp.trailers.allitems()) == response_trailers - - @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize( ("headers", "trailers", "response_headers", "response_trailers"), _headers_cases ) -async def test_headers_async( - headers, trailers, response_headers, response_trailers +async def test_headers( + mode, headers, trailers, response_headers, response_trailers ) -> None: - class HeadersHaberdasher(Haberdasher): - def __init__( - self, headers: list[tuple[str, str]], trailers: list[tuple[str, str]] - ) -> None: - self.headers = headers - self.trailers = trailers - - async def make_hat(self, _request, ctx): - for key, value in self.headers: - ctx.response_headers.add(key, value) - for key, value in self.trailers: - ctx.response_trailers.add(key, value) - return Hat() - - transport = ASGITransport( - HaberdasherASGIApplication(HeadersHaberdasher(headers, trailers)) - ) - - client = HaberdasherClient( - "http://localhost", http_client=Client(transport=transport) - ) + if mode == "async": + + class HeadersHaberdasher(Haberdasher): + async def make_hat(self, _request, ctx): + for key, value in headers: + ctx.response_headers.add(key, value) + for key, value in trailers: + ctx.response_trailers.add(key, value) + return Hat() + + client = HaberdasherClient( + "http://localhost", + http_client=Client( + ASGITransport(HaberdasherASGIApplication(HeadersHaberdasher())) + ), + ) + else: + + class HeadersHaberdasherSync(HaberdasherSync): + def make_hat(self, _request, ctx): + for key, value in headers: + ctx.response_headers.add(key, value) + for key, value in trailers: + ctx.response_trailers.add(key, value) + return Hat() + + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient( + WSGITransport(HaberdasherWSGIApplication(HeadersHaberdasherSync())) + ), + ) with ResponseMetadata() as resp: assert resp.http_status is None assert list(resp.headers.allitems()) == [] assert list(resp.trailers.allitems()) == [] - await client.make_hat(Size(inches=10)) + await call(client.make_hat, Size(inches=10)) assert resp.http_status == 200 assert list(resp.headers.allitems()) == response_headers diff --git a/test/test_roundtrip.py b/test/test_roundtrip.py index 9a09820b..8c91f486 100644 --- a/test/test_roundtrip.py +++ b/test/test_roundtrip.py @@ -15,7 +15,7 @@ from connectrpc.errors import ConnectError from connectrpc.server import DEFAULT_READ_MAX_BYTES -from ._util import resolve_compression +from ._util import call, resolve_compression from .connectrpc.example.haberdasher_connect import ( Haberdasher, HaberdasherASGIApplication, @@ -32,50 +32,47 @@ from asgiref.typing import HTTPDisconnectEvent, HTTPRequestEvent, HTTPScope +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("proto_json", [False, True]) @pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) -def test_roundtrip_sync(proto_json: bool, compression_name: str) -> None: - class RoundtripHaberdasherSync(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - +async def test_roundtrip(mode: str, proto_json: bool, compression_name: str) -> None: compression = resolve_compression(compression_name) - app = HaberdasherWSGIApplication( - RoundtripHaberdasherSync(), compressions=[compression] - ) - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app=app)), - codec=proto_json_codec() if proto_json else None, - send_compression=compression, - accept_compression=[compression], - ) as client: - response = client.make_hat(request=Size(inches=10)) - assert response.size == 10 - assert response.color == "green" + codec = proto_json_codec() if proto_json else None + if mode == "async": + class RoundtripHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") -@pytest.mark.parametrize("proto_json", [False, True]) -@pytest.mark.parametrize("compression_name", ["gzip", "br", "zstd", "identity"]) -@pytest.mark.asyncio -async def test_roundtrip_async(proto_json: bool, compression_name: str) -> None: - class DetailsHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") + app = HaberdasherASGIApplication( + RoundtripHaberdasher(), compressions=[compression] + ) + client = HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), + codec=codec, + send_compression=compression, + accept_compression=[compression], + ) + else: - compression = resolve_compression(compression_name) - app = HaberdasherASGIApplication(DetailsHaberdasher(), compressions=[compression]) - transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport), - codec=proto_json_codec() if proto_json else None, - send_compression=compression, - accept_compression=[compression], - ) as client: - response = await client.make_hat(request=Size(inches=10)) - assert response.size == 10 - assert response.color == "green" + class RoundtripHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + + app = HaberdasherWSGIApplication( + RoundtripHaberdasherSync(), compressions=[compression] + ) + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + codec=codec, + send_compression=compression, + accept_compression=[compression], + ) + response = await call(client.make_hat, Size(inches=10)) + assert response == Hat(size=10, color="green") # A request and a response containing a field the receiver's schema doesn't have, @@ -215,44 +212,43 @@ async def app(_scope, _receive, send): await client.make_hat(request=Size(inches=10)) -def test_roundtrip_sync_connect_get_empty_request() -> None: - class RoundtripHaberdasherSync(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") - +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) +async def test_roundtrip_connect_get_empty_request(mode: str) -> None: compression = resolve_compression("identity") - app = HaberdasherWSGIApplication( - RoundtripHaberdasherSync(), compressions=[compression] - ) - with HaberdasherClientSync( - "http://localhost", - http_client=SyncClient(WSGITransport(app=app)), - send_compression=compression, - accept_compression=[compression], - ) as client: - response = client.make_hat(request=Size(), use_get=True) - assert response.size == 0 - assert response.color == "green" + if mode == "async": + class RoundtripHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") -@pytest.mark.asyncio -async def test_roundtrip_async_connect_get_empty_request() -> None: - class RoundtripHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=request.inches, color="green") + app = HaberdasherASGIApplication( + RoundtripHaberdasher(), compressions=[compression] + ) + client = HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), + send_compression=compression, + accept_compression=[compression], + ) + response = await client.make_hat(Size(), use_get=True) + else: - compression = resolve_compression("identity") - app = HaberdasherASGIApplication(RoundtripHaberdasher(), compressions=[compression]) - transport = ASGITransport(app) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=transport), - send_compression=compression, - accept_compression=[compression], - ) as client: - response = await client.make_hat(request=Size(), use_get=True) - assert response.size == 0 - assert response.color == "green" + class RoundtripHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="green") + + app = HaberdasherWSGIApplication( + RoundtripHaberdasherSync(), compressions=[compression] + ) + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + send_compression=compression, + accept_compression=[compression], + ) + response = client.make_hat(Size(), use_get=True) + assert response == Hat(size=0, color="green") @pytest.mark.parametrize("proto_json", [False, True]) @@ -469,49 +465,39 @@ async def request_stream(): assert len(responses) == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("large", [False, True]) -def test_message_limit_unary_error_sync(large: bool) -> None: +async def test_message_limit_unary_error(mode: str, large: bool) -> None: message = "x" * 100 if large else "small" + if mode == "async": - class FailingHaberdasher(HaberdasherSync): - def make_hat(self, _request, _ctx): - raise ConnectError(Code.FAILED_PRECONDITION, message) + class FailingHaberdasher(Haberdasher): + async def make_hat(self, _request, _ctx): + raise ConnectError(Code.FAILED_PRECONDITION, message) - app = HaberdasherWSGIApplication(FailingHaberdasher()) - with ( - HaberdasherClientSync( + client = HaberdasherClient( "http://localhost", - http_client=SyncClient(WSGITransport(app)), + http_client=Client( + ASGITransport(HaberdasherASGIApplication(FailingHaberdasher())) + ), read_max_bytes=100, - ) as client, - pytest.raises(ConnectError) as exc_info, - ): - client.make_hat(request=Size()) - if large: - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert exc_info.value.message == "message is larger than configured max 100" + ) else: - assert exc_info.value.code == Code.FAILED_PRECONDITION - assert exc_info.value.message == message - - -@pytest.mark.parametrize("large", [False, True]) -@pytest.mark.asyncio -async def test_message_limit_unary_error_async(large: bool) -> None: - message = "x" * 100 if large else "small" - class FailingHaberdasher(Haberdasher): - async def make_hat(self, _request, _ctx): - raise ConnectError(Code.FAILED_PRECONDITION, message) + class FailingHaberdasherSync(HaberdasherSync): + def make_hat(self, _request, _ctx): + raise ConnectError(Code.FAILED_PRECONDITION, message) - app = HaberdasherASGIApplication(FailingHaberdasher()) - async with HaberdasherClient( - "http://localhost", - http_client=Client(transport=ASGITransport(app)), - read_max_bytes=100, - ) as client: - with pytest.raises(ConnectError) as exc_info: - await client.make_hat(request=Size()) + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient( + WSGITransport(HaberdasherWSGIApplication(FailingHaberdasherSync())) + ), + read_max_bytes=100, + ) + with pytest.raises(ConnectError) as exc_info: + await call(client.make_hat, Size()) if large: assert exc_info.value.code == Code.RESOURCE_EXHAUSTED assert exc_info.value.message == "message is larger than configured max 100" @@ -524,65 +510,52 @@ async def make_hat(self, _request, _ctx): _BIG_DESCRIPTION_LENGTH = DEFAULT_READ_MAX_BYTES + 1 - 5 +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["async", "sync"]) @pytest.mark.parametrize("unlimited", [False, True]) -def test_message_limit_default_sync(unlimited: bool) -> None: - class EchoSizeHaberdasher(HaberdasherSync): - def make_hat(self, request, _ctx): - return Hat(size=len(request.description)) - +async def test_message_limit_default(mode: str, unlimited: bool) -> None: big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 # We specifically want to test not setting vs setting to None - app = ( - HaberdasherWSGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) - if unlimited - else HaberdasherWSGIApplication(EchoSizeHaberdasher()) - ) - with HaberdasherClientSync( - "http://localhost", http_client=SyncClient(WSGITransport(app)) - ) as client: - if unlimited: - response = client.make_hat(request=big_size) - assert response.size == _BIG_DESCRIPTION_LENGTH - else: - with pytest.raises(ConnectError) as exc_info: - client.make_hat(request=big_size) - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert ( - exc_info.value.message - == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" - ) + if mode == "async": + class EchoSizeHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=len(request.description)) -@pytest.mark.parametrize("unlimited", [False, True]) -@pytest.mark.asyncio -async def test_message_limit_default_async(unlimited: bool) -> None: - class EchoSizeHaberdasher(Haberdasher): - async def make_hat(self, request, _ctx): - return Hat(size=len(request.description)) + app = ( + HaberdasherASGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) + if unlimited + else HaberdasherASGIApplication(EchoSizeHaberdasher()) + ) + client = HaberdasherClient( + "http://localhost", http_client=Client(ASGITransport(app)) + ) + else: - big_size = Size(description="X" * _BIG_DESCRIPTION_LENGTH) - assert len(big_size.to_binary()) == DEFAULT_READ_MAX_BYTES + 1 - # We specifically want to test not setting vs setting to None - app = ( - HaberdasherASGIApplication(EchoSizeHaberdasher(), read_max_bytes=None) - if unlimited - else HaberdasherASGIApplication(EchoSizeHaberdasher()) - ) - async with HaberdasherClient( - "http://localhost", http_client=Client(transport=ASGITransport(app)) - ) as client: - if unlimited: - response = await client.make_hat(request=big_size) - assert response.size == _BIG_DESCRIPTION_LENGTH - else: - with pytest.raises(ConnectError) as exc_info: - await client.make_hat(request=big_size) - assert exc_info.value.code == Code.RESOURCE_EXHAUSTED - assert ( - exc_info.value.message - == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" - ) + class EchoSizeHaberdasherSync(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=len(request.description)) + + app = ( + HaberdasherWSGIApplication(EchoSizeHaberdasherSync(), read_max_bytes=None) + if unlimited + else HaberdasherWSGIApplication(EchoSizeHaberdasherSync()) + ) + client = HaberdasherClientSync( + "http://localhost", http_client=SyncClient(WSGITransport(app)) + ) + if unlimited: + response = await call(client.make_hat, big_size) + assert response == Hat(size=_BIG_DESCRIPTION_LENGTH) + else: + with pytest.raises(ConnectError) as exc_info: + await call(client.make_hat, big_size) + assert exc_info.value.code == Code.RESOURCE_EXHAUSTED + assert ( + exc_info.value.message + == f"message is larger than configured max {DEFAULT_READ_MAX_BYTES}" + ) @pytest.mark.asyncio