Skip to content

Commit 2acc9c1

Browse files
committed
Patch httpcore instead of httpx
1 parent ac70eaa commit 2acc9c1

File tree

5 files changed

+231
-213
lines changed

5 files changed

+231
-213
lines changed

docs/installation.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The following HTTP libraries are supported:
2222
- ``urllib2``
2323
- ``urllib3``
2424
- ``httpx``
25+
- ``httpcore``
2526

2627
Speed
2728
-----

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def find_version(*file_paths):
4848
"tests": [
4949
"aiohttp",
5050
"boto3",
51+
"httpcore",
5152
"httplib2",
5253
"httpx",
5354
"pytest-aiohttp",

vcr/patch.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,12 @@
9292

9393

9494
try:
95-
import httpx
95+
import httpcore
9696
except ImportError: # pragma: no cover
9797
pass
9898
else:
99-
_HttpxSyncClient_send_single_request = httpx.Client._send_single_request
100-
_HttpxAsyncClient_send_single_request = httpx.AsyncClient._send_single_request
99+
_HttpcoreConnectionPool_handle_request = httpcore.ConnectionPool.handle_request
100+
_HttpcoreAsyncConnectionPool_handle_async_request = httpcore.AsyncConnectionPool.handle_async_request
101101

102102

103103
class CassettePatcherBuilder:
@@ -121,7 +121,7 @@ def build(self):
121121
self._httplib2(),
122122
self._tornado(),
123123
self._aiohttp(),
124-
self._httpx(),
124+
self._httpcore(),
125125
self._build_patchers_from_mock_triples(self._cassette.custom_patches),
126126
)
127127

@@ -304,19 +304,22 @@ def _aiohttp(self):
304304
yield client.ClientSession, "_request", new_request
305305

306306
@_build_patchers_from_mock_triples_decorator
307-
def _httpx(self):
307+
def _httpcore(self):
308308
try:
309-
import httpx
309+
import httpcore
310310
except ImportError: # pragma: no cover
311311
return
312312
else:
313-
from .stubs.httpx_stubs import async_vcr_send, sync_vcr_send
313+
from .stubs.httpcore_stubs import vcr_handle_async_request, vcr_handle_request
314314

315-
new_async_client_send = async_vcr_send(self._cassette, _HttpxAsyncClient_send_single_request)
316-
yield httpx.AsyncClient, "_send_single_request", new_async_client_send
315+
new_handle_async_request = vcr_handle_async_request(
316+
self._cassette,
317+
_HttpcoreAsyncConnectionPool_handle_async_request,
318+
)
319+
yield httpcore.AsyncConnectionPool, "handle_async_request", new_handle_async_request
317320

318-
new_sync_client_send = sync_vcr_send(self._cassette, _HttpxSyncClient_send_single_request)
319-
yield httpx.Client, "_send_single_request", new_sync_client_send
321+
new_handle_request = vcr_handle_request(self._cassette, _HttpcoreConnectionPool_handle_request)
322+
yield httpcore.ConnectionPool, "handle_request", new_handle_request
320323

321324
def _urllib3_patchers(self, cpool, conn, stubs):
322325
http_connection_remover = ConnectionRemover(

vcr/stubs/httpcore_stubs.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import asyncio
2+
import functools
3+
import logging
4+
from collections import defaultdict
5+
from collections.abc import AsyncIterable, Iterable
6+
7+
from httpcore import Response
8+
from httpcore._models import ByteStream
9+
10+
from vcr.errors import CannotOverwriteExistingCassetteException
11+
from vcr.filters import decode_response
12+
from vcr.request import Request as VcrRequest
13+
from vcr.serializers.compat import convert_body_to_bytes
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
async def _convert_byte_stream(stream):
19+
if isinstance(stream, Iterable):
20+
return list(stream)
21+
22+
if isinstance(stream, AsyncIterable):
23+
return [part async for part in stream]
24+
25+
raise TypeError(
26+
f"_convert_byte_stream: stream must be Iterable or AsyncIterable, got {type(stream).__name__}",
27+
)
28+
29+
30+
def _serialize_headers(real_response):
31+
"""
32+
Some headers can appear multiple times, like "Set-Cookie".
33+
Therefore serialize every header key to a list of values.
34+
"""
35+
36+
headers = defaultdict(list)
37+
38+
for name, value in real_response.headers:
39+
headers[name.decode("ascii")].append(value.decode("ascii"))
40+
41+
return dict(headers)
42+
43+
44+
async def _serialize_response(real_response):
45+
# The reason_phrase may not exist
46+
try:
47+
reason_phrase = real_response.extensions["reason_phrase"].decode("ascii")
48+
except KeyError:
49+
reason_phrase = None
50+
51+
# Reading the response stream consumes the iterator, so we need to restore it afterwards
52+
content = b"".join(await _convert_byte_stream(real_response.stream))
53+
real_response.stream = ByteStream(content)
54+
55+
return {
56+
"status": {"code": real_response.status, "message": reason_phrase},
57+
"headers": _serialize_headers(real_response),
58+
"body": {"string": content},
59+
}
60+
61+
62+
def _deserialize_headers(headers):
63+
"""
64+
httpcore accepts headers as list of tuples of header key and value.
65+
"""
66+
67+
return [
68+
(name.encode("ascii"), value.encode("ascii")) for name, values in headers.items() for value in values
69+
]
70+
71+
72+
def _deserialize_response(vcr_response):
73+
# Cassette format generated for HTTPX requests by older versions of
74+
# vcrpy. We restructure the content to resemble what a regular
75+
# cassette looks like.
76+
if "status_code" in vcr_response:
77+
vcr_response = decode_response(
78+
convert_body_to_bytes(
79+
{
80+
"headers": vcr_response["headers"],
81+
"body": {"string": vcr_response["content"]},
82+
"status": {"code": vcr_response["status_code"]},
83+
},
84+
),
85+
)
86+
extensions = None
87+
else:
88+
extensions = (
89+
{"reason_phrase": vcr_response["status"]["message"].encode("ascii")}
90+
if vcr_response["status"]["message"]
91+
else None
92+
)
93+
94+
return Response(
95+
vcr_response["status"]["code"],
96+
headers=_deserialize_headers(vcr_response["headers"]),
97+
content=vcr_response["body"]["string"],
98+
extensions=extensions,
99+
)
100+
101+
102+
async def _make_vcr_request(real_request):
103+
# Reading the request stream consumes the iterator, so we need to restore it afterwards
104+
body = b"".join(await _convert_byte_stream(real_request.stream))
105+
real_request.stream = ByteStream(body)
106+
107+
uri = bytes(real_request.url).decode("ascii")
108+
109+
# As per HTTPX: If there are multiple headers with the same key, then we concatenate them with commas
110+
headers = defaultdict(list)
111+
112+
for name, value in real_request.headers:
113+
headers[name.decode("ascii")].append(value.decode("ascii"))
114+
115+
headers = {name: ", ".join(values) for name, values in headers.items()}
116+
117+
return VcrRequest(real_request.method.decode("ascii"), uri, body, headers)
118+
119+
120+
async def _vcr_request(cassette, real_request):
121+
vcr_request = await _make_vcr_request(real_request)
122+
123+
if cassette.can_play_response_for(vcr_request):
124+
return vcr_request, _play_responses(cassette, vcr_request)
125+
126+
if cassette.write_protected and cassette.filter_request(vcr_request):
127+
raise CannotOverwriteExistingCassetteException(
128+
cassette=cassette,
129+
failed_request=vcr_request,
130+
)
131+
132+
_logger.info("%s not in cassette, sending to real server", vcr_request)
133+
134+
return vcr_request, None
135+
136+
137+
async def _record_responses(cassette, vcr_request, real_response):
138+
cassette.append(vcr_request, await _serialize_response(real_response))
139+
140+
141+
def _play_responses(cassette, vcr_request):
142+
vcr_response = cassette.play_response(vcr_request)
143+
real_response = _deserialize_response(vcr_response)
144+
145+
return real_response
146+
147+
148+
async def _vcr_handle_async_request(
149+
cassette,
150+
real_handle_async_request,
151+
self,
152+
real_request,
153+
):
154+
vcr_request, vcr_response = await _vcr_request(cassette, real_request)
155+
156+
if vcr_response:
157+
return vcr_response
158+
159+
real_response = await real_handle_async_request(self, real_request)
160+
await _record_responses(cassette, vcr_request, real_response)
161+
162+
return real_response
163+
164+
165+
def vcr_handle_async_request(cassette, real_handle_async_request):
166+
@functools.wraps(real_handle_async_request)
167+
def _inner_handle_async_request(self, real_request):
168+
return _vcr_handle_async_request(
169+
cassette,
170+
real_handle_async_request,
171+
self,
172+
real_request,
173+
)
174+
175+
return _inner_handle_async_request
176+
177+
178+
def _run_async_function(sync_func, *args, **kwargs):
179+
"""
180+
Safely run an asynchronous function from a synchronous context.
181+
Handles both cases:
182+
- An event loop is already running.
183+
- No event loop exists yet.
184+
"""
185+
try:
186+
asyncio.get_running_loop()
187+
except RuntimeError:
188+
return asyncio.run(sync_func(*args, **kwargs))
189+
else:
190+
# If inside a running loop, create a task and wait for it
191+
return asyncio.ensure_future(sync_func(*args, **kwargs))
192+
193+
194+
def _vcr_handle_request(cassette, real_handle_request, self, real_request):
195+
vcr_request, vcr_response = _run_async_function(
196+
_vcr_request,
197+
cassette,
198+
real_request,
199+
)
200+
201+
if vcr_response:
202+
return vcr_response
203+
204+
real_response = real_handle_request(self, real_request)
205+
_run_async_function(_record_responses, cassette, vcr_request, real_response)
206+
207+
return real_response
208+
209+
210+
def vcr_handle_request(cassette, real_handle_request):
211+
@functools.wraps(real_handle_request)
212+
def _inner_handle_request(self, real_request):
213+
return _vcr_handle_request(cassette, real_handle_request, self, real_request)
214+
215+
return _inner_handle_request

0 commit comments

Comments
 (0)