@@ -33,100 +33,28 @@ def pytest_make_collect_report(collector):
3333
3434
3535def _patch_vcr_aiohttp_stubs ():
36- """Patch VCR.py's aiohttp stubs to fix bugs with google-genai >= 1.64.0 and litellm.
37-
38- Problems fixed:
39- 1. Infinite loop: VCR's MockClientResponse.content is a @property that creates
40- a new MockStream on every access. google-genai reads streaming responses via
41- `while True: await response.content.readline()`, creating a fresh stream each
42- iteration that never reaches EOF. Fix: cache the stream per instance.
43-
44- 2. Gzip decoding: Cassettes store gzip-compressed response bodies (from
45- Accept-Encoding: gzip). VCR's httpx stubs handle decompression, but the
46- aiohttp stubs return raw gzip bytes, causing UnicodeDecodeError.
47- Fix: decompress gzip in text(), read(), and the content stream.
48-
49- 3. set_exception: aiohttp's close() sets a ClientConnectionError on the content
50- stream, which then raises on subsequent reads. Fix: no-op set_exception on
51- MockStream.
52-
53- 4. Body consumed during recording: VCR's record_response() calls
54- `await response.read()` which consumes the real response body. When
55- litellm's aiohttp transport then tries to stream the response, the body
56- is empty. Fix: after reading the body for recording, reset the response's
57- content stream so it can be read again by the caller.
58-
59- VCR.py releases with kevin1024/vcrpy#1055 fix 1, 3 and 4 themselves, so the
60- patches for those (including gzip decoding of the content stream) only apply
61- to older releases. Delete them once uv.lock has such a release.
62-
63- See: https://github.com/kevin1024/vcrpy/issues/927
36+ """Patch VCR.py's aiohttp stubs to decompress gzip response bodies.
37+
38+ Cassettes store gzip-compressed response bodies (from Accept-Encoding: gzip).
39+ VCR's httpx stubs handle decompression, but the aiohttp stubs return raw gzip
40+ bytes, causing UnicodeDecodeError. Fix: decompress gzip in text() and read().
6441 """
6542 try :
66- from aiohttp import streams
67-
68- # VCR.py imports this aiohttp internals symbol at module import time, but
69- # newer aiohttp releases removed it. Provide a no-op compatibility shim
70- # before importing vcr.stubs.aiohttp_stubs so test collection does not fail.
71- if not hasattr (streams , "AsyncStreamReaderMixin" ):
72- setattr (streams , "AsyncStreamReaderMixin" , object )
73-
7443 from vcr .stubs import aiohttp_stubs
7544 except ImportError :
7645 return
7746
7847 if getattr (aiohttp_stubs .MockClientResponse , "_bt_patched" , False ):
7948 return
8049
81- import asyncio
8250 import gzip
83- import inspect
84-
85- from aiohttp import ClientConnectionError , ClientResponse , streams
86- from aiohttp .helpers import TimerNoop
8751
8852 def _decompress_body (body ):
8953 """Decompress gzip body if needed."""
9054 if body and body [:2 ] == b"\x1f \x8b " :
9155 return gzip .decompress (body )
9256 return body
9357
94- if "stream_writer" in inspect .signature (ClientResponse .__init__ ).parameters :
95- # aiohttp 3.13 added a required stream_writer keyword argument to
96- # ClientResponse.__init__. VCR.py's MockClientResponse still calls the
97- # older constructor, so patch it before any cassette playback builds one.
98- class _MockStreamWriter :
99- output_size = 0
100-
101- def patched_response_init (self , method , url , request_info = None ):
102- super (aiohttp_stubs .MockClientResponse , self ).__init__ (
103- method = method ,
104- url = url ,
105- writer = None ,
106- continue100 = None ,
107- timer = TimerNoop (),
108- request_info = request_info ,
109- traces = [],
110- loop = asyncio .get_event_loop (),
111- session = None ,
112- stream_writer = _MockStreamWriter (),
113- )
114-
115- aiohttp_stubs .MockClientResponse .__init__ = patched_response_init
116-
117- if not hasattr (aiohttp_stubs .MockStream , "iter_chunked" ):
118- # Older aiohttp exposed iter_chunked via AsyncStreamReaderMixin. Since
119- # that mixin is gone, VCR.py's MockStream needs the method directly for
120- # clients that consume aiohttp responses through chunked iteration.
121- async def iter_chunked (self , n ):
122- while True :
123- chunk = await self .read (n )
124- if not chunk :
125- break
126- yield chunk
127-
128- aiohttp_stubs .MockStream .iter_chunked = iter_chunked
129-
13058 async def patched_text (self , encoding = "utf-8" , errors = "strict" ):
13159 return _decompress_body (self ._body ).decode (encoding , errors = errors )
13260
@@ -138,60 +66,6 @@ async def patched_read(self):
13866 aiohttp_stubs .MockClientResponse .read = patched_read
13967 aiohttp_stubs .MockClientResponse ._bt_patched = True
14068
141- if issubclass (aiohttp_stubs .MockStream , streams .StreamReader ):
142- # VCR.py with kevin1024/vcrpy#1055 (MockStream is then aiohttp's StreamReader)
143- # fixes 1, 3 and 4 itself, and MockStream() without the body raises there.
144- return
145-
146- aiohttp_stubs .MockStream .set_exception = lambda self , exc : None
147-
148- @property
149- def cached_content (self ):
150- if not hasattr (self , "_cached_content" ):
151- stream = aiohttp_stubs .MockStream () # pylint: disable=no-value-for-parameter
152- stream .feed_data (_decompress_body (self ._body ))
153- stream .feed_eof ()
154- self ._cached_content = stream
155- return self ._cached_content
156-
157- aiohttp_stubs .MockClientResponse .content = cached_content
158-
159- # Patch record_response to not consume the response body. VCR's original
160- # implementation calls `await response.read()` which exhausts the body,
161- # making it unavailable for the actual caller (e.g. litellm's aiohttp
162- # transport). We read the body, record it, then reset the content stream
163- # so downstream consumers can still read it.
164- _original_record_response = aiohttp_stubs .record_response
165-
166- async def _patched_record_response (cassette , vcr_request , response ):
167- try :
168- body_bytes = await response .read ()
169- except ClientConnectionError :
170- body_bytes = b""
171-
172- vcr_response = {
173- "status" : {"code" : response .status , "message" : response .reason },
174- "headers" : aiohttp_stubs ._serialize_headers (response .headers ),
175- "body" : {"string" : body_bytes } if body_bytes else {},
176- }
177- cassette .append (vcr_request , vcr_response )
178-
179- # Reset the response content stream so the caller can still read it.
180- # aiohttp's ClientResponse stores the payload in response.content which
181- # is a StreamReader. After read() exhausts it, we replace it with a new
182- # stream containing the same data.
183- # Use a limit larger than the captured body so StreamReader.feed_data()
184- # does not try to pause the original aiohttp parser. Large responses
185- # (for example image-generation payloads) can otherwise trip an
186- # assertion inside aiohttp's parser when VCR records the response.
187- stream_limit = max (2 ** 16 , len (body_bytes ) + 1 )
188- new_stream = streams .StreamReader (response ._protocol , stream_limit , loop = asyncio .get_event_loop ())
189- new_stream .feed_data (body_bytes )
190- new_stream .feed_eof ()
191- response .content = new_stream
192-
193- aiohttp_stubs .record_response = _patched_record_response
194-
19569
19670_patch_vcr_aiohttp_stubs ()
19771
0 commit comments