Skip to content

Commit b41712a

Browse files
committed
Migrate to pyqwest
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
1 parent 10958f5 commit b41712a

21 files changed

Lines changed: 426 additions & 608 deletions

README.md

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This repo provides a Python implementation of Connect, including both client and
1111

1212
## Features
1313

14-
- **Clients**: Both synchronous and asynchronous clients backed by [httpx](https://www.python-httpx.org/)
14+
- **Clients**: Both synchronous and asynchronous clients backed by [pyqwest](https://pyqwest.dev/)
1515
- **Servers**: WSGI and ASGI server implementations for use with any Python app server
1616
- **Type Safety**: Fully type-annotated, including the generated code
1717
- **Compression**: Built-in support for gzip, brotli, and zstd compression
@@ -63,8 +63,8 @@ it can be referenced as `protoc-gen-connect-python`.
6363
Then, you can use `protoc-gen-connect-python` as a local plugin:
6464

6565
```yaml
66-
- local: .venv/bin/protoc-gen-connect-python
67-
out: .
66+
- local: .venv/bin/protoc-gen-connect-python
67+
out: .
6868
```
6969

7070
Alternatively, download a precompiled binary from the
@@ -79,18 +79,14 @@ For more usage details, see the [docs](./docs/usage.md).
7979
### Basic Client Usage
8080

8181
```python
82-
import httpx
8382
from your_service_pb2 import HelloRequest, HelloResponse
8483
from your_service_connect import HelloServiceClient
8584
8685
# Create async client
8786
async def main():
88-
async with httpx.AsyncClient() as session:
89-
client = HelloServiceClient(
90-
base_url="https://api.example.com",
91-
session=session
92-
)
93-
87+
async with HelloServiceClient(
88+
base_url="https://api.example.com",
89+
) as client:
9490
# Make a unary RPC call
9591
response = await client.say_hello(HelloRequest(name="World"))
9692
print(response.message) # "Hello, World!"
@@ -117,18 +113,14 @@ app = HelloServiceASGIApplication(MyHelloService())
117113
### Basic Client Usage (Synchronous)
118114

119115
```python
120-
import httpx
121116
from your_service_pb2 import HelloRequest
122117
from your_service_connect import HelloServiceClientSync
123118
124119
# Create sync client
125120
def main():
126-
with httpx.Client() as session:
127-
client = HelloServiceClientSync(
128-
base_url="https://api.example.com",
129-
session=session
130-
)
131-
121+
with HelloServiceClientSync(
122+
base_url="https://api.example.com",
123+
) as client:
132124
# Make a unary RPC call
133125
response = client.say_hello(HelloRequest(name="World"))
134126
print(response.message) # "Hello, World!"

conformance/test/client.py

Lines changed: 27 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@
55
import contextlib
66
import multiprocessing
77
import queue
8-
import ssl
98
import sys
109
import time
1110
import traceback
12-
from tempfile import NamedTemporaryFile
1311
from typing import TYPE_CHECKING, Literal, TypeVar, get_args
1412

15-
import httpx
1613
from _util import create_standard_streams
1714
from gen.connectrpc.conformance.v1.client_compat_pb2 import (
1815
ClientCompatRequest,
@@ -40,9 +37,8 @@
4037
UnimplementedRequest,
4138
)
4239
from google.protobuf.message import Message
43-
from pyqwest import HTTPTransport, SyncHTTPTransport
40+
from pyqwest import Client, HTTPTransport, SyncClient, SyncHTTPTransport
4441
from pyqwest import HTTPVersion as PyQwestHTTPVersion
45-
from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport
4642

4743
from connectrpc.client import ResponseMetadata
4844
from connectrpc.code import Code
@@ -118,41 +114,8 @@ def _unpack_request(message: Any, request: T) -> T:
118114
return request
119115

120116

121-
async def httpx_client_kwargs(test_request: ClientCompatRequest) -> dict:
122-
kwargs = {}
123-
match test_request.http_version:
124-
case HTTPVersion.HTTP_VERSION_1:
125-
kwargs["http1"] = True
126-
kwargs["http2"] = False
127-
case HTTPVersion.HTTP_VERSION_2:
128-
kwargs["http1"] = False
129-
kwargs["http2"] = True
130-
if test_request.server_tls_cert:
131-
ctx = ssl.create_default_context(
132-
purpose=ssl.Purpose.SERVER_AUTH,
133-
cadata=test_request.server_tls_cert.decode(),
134-
)
135-
if test_request.HasField("client_tls_creds"):
136-
137-
def load_certs() -> None:
138-
with (
139-
NamedTemporaryFile() as cert_file,
140-
NamedTemporaryFile() as key_file,
141-
):
142-
cert_file.write(test_request.client_tls_creds.cert)
143-
cert_file.flush()
144-
key_file.write(test_request.client_tls_creds.key)
145-
key_file.flush()
146-
ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
147-
148-
await asyncio.to_thread(load_certs)
149-
kwargs["verify"] = ctx
150-
151-
return kwargs
152-
153-
154117
def pyqwest_client_kwargs(test_request: ClientCompatRequest) -> dict:
155-
kwargs: dict = {"enable_gzip": True, "enable_brotli": True, "enable_zstd": True}
118+
kwargs: dict = {}
156119
match test_request.http_version:
157120
case HTTPVersion.HTTP_VERSION_1:
158121
kwargs["http_version"] = PyQwestHTTPVersion.HTTP1
@@ -169,28 +132,26 @@ def pyqwest_client_kwargs(test_request: ClientCompatRequest) -> dict:
169132

170133
@contextlib.asynccontextmanager
171134
async def client_sync(
172-
test_request: ClientCompatRequest, client_type: Client
135+
test_request: ClientCompatRequest,
173136
) -> AsyncIterator[ConformanceServiceClientSync]:
174137
read_max_bytes = None
175138
if test_request.message_receive_limit:
176139
read_max_bytes = test_request.message_receive_limit
177140
scheme = "https" if test_request.server_tls_cert else "http"
141+
args = pyqwest_client_kwargs(test_request)
142+
178143
cleanup = contextlib.ExitStack()
179-
match client_type:
180-
case "httpx":
181-
args = await httpx_client_kwargs(test_request)
182-
session = cleanup.enter_context(httpx.Client(**args))
183-
case "pyqwest":
184-
args = pyqwest_client_kwargs(test_request)
185-
http_transport = cleanup.enter_context(SyncHTTPTransport(**args))
186-
transport = cleanup.enter_context(PyqwestTransport(http_transport))
187-
session = cleanup.enter_context(httpx.Client(transport=transport))
144+
if args:
145+
transport = cleanup.enter_context(SyncHTTPTransport(**args))
146+
http_client = SyncClient(transport)
147+
else:
148+
http_client = None
188149

189150
with (
190151
cleanup,
191152
ConformanceServiceClientSync(
192153
f"{scheme}://{test_request.host}:{test_request.port}",
193-
session=session,
154+
http_client=http_client,
194155
send_compression=_convert_compression(test_request.compression),
195156
proto_json=test_request.codec == Codec.CODEC_JSON,
196157
grpc=test_request.protocol == Protocol.PROTOCOL_GRPC,
@@ -202,32 +163,29 @@ async def client_sync(
202163

203164
@contextlib.asynccontextmanager
204165
async def client_async(
205-
test_request: ClientCompatRequest, client_type: Client
166+
test_request: ClientCompatRequest,
206167
) -> AsyncIterator[ConformanceServiceClient]:
207168
read_max_bytes = None
208169
if test_request.message_receive_limit:
209170
read_max_bytes = test_request.message_receive_limit
210171
scheme = "https" if test_request.server_tls_cert else "http"
172+
args = pyqwest_client_kwargs(test_request)
173+
211174
cleanup = contextlib.AsyncExitStack()
212-
match client_type:
213-
case "httpx":
214-
args = await httpx_client_kwargs(test_request)
215-
session = await cleanup.enter_async_context(httpx.AsyncClient(**args))
216-
case "pyqwest":
217-
args = pyqwest_client_kwargs(test_request)
218-
http_transport = await cleanup.enter_async_context(HTTPTransport(**args))
219-
transport = await cleanup.enter_async_context(
220-
AsyncPyqwestTransport(http_transport)
221-
)
222-
session = await cleanup.enter_async_context(
223-
httpx.AsyncClient(transport=transport)
224-
)
175+
if args:
176+
transport = HTTPTransport(**args)
177+
# Type parameter for enter_async_context requires coroutine even though
178+
# implementation doesn't. We can directly push aexit to work around it.
179+
cleanup.push_async_exit(transport.__aexit__)
180+
http_client = Client(transport)
181+
else:
182+
http_client = None
225183

226184
async with (
227185
cleanup,
228186
ConformanceServiceClient(
229187
f"{scheme}://{test_request.host}:{test_request.port}",
230-
session=session,
188+
http_client=http_client,
231189
send_compression=_convert_compression(test_request.compression),
232190
proto_json=test_request.codec == Codec.CODEC_JSON,
233191
grpc=test_request.protocol == Protocol.PROTOCOL_GRPC,
@@ -238,7 +196,7 @@ async def client_async(
238196

239197

240198
async def _run_test(
241-
mode: Mode, test_request: ClientCompatRequest, client_type: Client
199+
mode: Mode, test_request: ClientCompatRequest
242200
) -> ClientCompatResponse:
243201
test_response = ClientCompatResponse()
244202
test_response.test_name = test_request.test_name
@@ -260,7 +218,7 @@ async def _run_test(
260218
request_closed = asyncio.Event()
261219
match mode:
262220
case "sync":
263-
async with client_sync(test_request, client_type) as client:
221+
async with client_sync(test_request) as client:
264222
match test_request.method:
265223
case "BidiStream":
266224
request_queue = queue.Queue()
@@ -468,7 +426,7 @@ def send_unary_request_sync(
468426
task.cancel()
469427
await task
470428
case "async":
471-
async with client_async(test_request, client_type) as client:
429+
async with client_async(test_request) as client:
472430
match test_request.method:
473431
case "BidiStream":
474432
request_queue = asyncio.Queue()
@@ -691,20 +649,17 @@ async def send_unary_request(
691649

692650

693651
Mode = Literal["sync", "async"]
694-
Client = Literal["httpx", "pyqwest"]
695652

696653

697654
class Args(argparse.Namespace):
698655
mode: Mode
699-
client: Client
700656
parallel: int
701657

702658

703659
async def main() -> None:
704660
parser = argparse.ArgumentParser(description="Conformance client")
705661
parser.add_argument("--mode", choices=get_args(Mode))
706662
parser.add_argument("--parallel", type=int, default=multiprocessing.cpu_count() * 4)
707-
parser.add_argument("--client", choices=get_args(Client))
708663
args = parser.parse_args(namespace=Args())
709664

710665
stdin, stdout = await create_standard_streams()
@@ -724,7 +679,7 @@ async def main() -> None:
724679

725680
async def task(request: ClientCompatRequest) -> None:
726681
async with sema:
727-
response = await _run_test(args.mode, request, args.client)
682+
response = await _run_test(args.mode, request)
728683

729684
response_buf = response.SerializeToString()
730685
size_buf = len(response_buf).to_bytes(4, byteorder="big")

conformance/test/test_client.py

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,40 +18,12 @@
1818
"Client Cancellation/**",
1919
]
2020

21-
_httpx_opts = [
22-
# Trailers not supported
23-
"--skip",
24-
"**/Protocol:PROTOCOL_GRPC/**",
25-
"--skip",
26-
"gRPC Trailers/**",
27-
"--skip",
28-
"gRPC Unexpected Responses/**",
29-
"--skip",
30-
"gRPC Empty Responses/**",
31-
"--skip",
32-
"gRPC Proto Sub-Format Responses/**",
33-
# Bidirectional streaming not supported
34-
"--skip",
35-
"**/full-duplex/**",
36-
# Cancellation delivery isn't reliable
37-
"--known-flaky",
38-
"Client Cancellation/**",
39-
"--known-flaky",
40-
"Timeouts/**",
41-
]
42-
4321

44-
@pytest.mark.parametrize("client", ["httpx", "pyqwest"])
45-
def test_client_sync(client: str) -> None:
22+
def test_client_sync() -> None:
4623
args = maybe_patch_args_with_debug(
47-
[sys.executable, _client_py_path, "--mode", "sync", "--client", client]
24+
[sys.executable, _client_py_path, "--mode", "sync"]
4825
)
4926

50-
opts = []
51-
match client:
52-
case "httpx":
53-
opts = _httpx_opts
54-
5527
result = subprocess.run(
5628
[
5729
"go",
@@ -61,7 +33,6 @@ def test_client_sync(client: str) -> None:
6133
_config_path,
6234
"--mode",
6335
"client",
64-
*opts,
6536
*_skipped_tests_sync,
6637
"--",
6738
*args,
@@ -74,17 +45,11 @@ def test_client_sync(client: str) -> None:
7445
pytest.fail(f"\n{result.stdout}\n{result.stderr}")
7546

7647

77-
@pytest.mark.parametrize("client", ["httpx", "pyqwest"])
78-
def test_client_async(client: str) -> None:
48+
def test_client_async() -> None:
7949
args = maybe_patch_args_with_debug(
80-
[sys.executable, _client_py_path, "--mode", "async", "--client", client]
50+
[sys.executable, _client_py_path, "--mode", "async"]
8151
)
8252

83-
opts = []
84-
match client:
85-
case "httpx":
86-
opts = _httpx_opts
87-
8853
result = subprocess.run(
8954
[
9055
"go",
@@ -94,7 +59,6 @@ def test_client_async(client: str) -> None:
9459
_config_path,
9560
"--mode",
9661
"client",
97-
*opts,
9862
"--",
9963
*args,
10064
],

0 commit comments

Comments
 (0)