Skip to content

Commit b7e3fbb

Browse files
authored
Merge pull request #1948 from Open-Source-Legal/claude/compassionate-gauss-caF9U
Lift aiohttp<3.14 cap with a vcrpy aiohttp-stub compat shim (closes #1920)
2 parents db2b07b + 56c4895 commit b7e3fbb

6 files changed

Lines changed: 153 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
- Lifted the temporary `aiohttp<3.14` cap in `requirements/base.txt` (added in
2+
#1914), returning `aiohttp` to its natural unpinned-transitive state so CI
3+
resolves the current 3.14+ line again. vcrpy 8.1.1 (the latest release, still
4+
pinned in `requirements/local.txt`) is not yet compatible with aiohttp 3.14:
5+
`vcr/stubs/aiohttp_stubs.py` subclasses `aiohttp.streams.AsyncStreamReaderMixin`
6+
at import time, a symbol aiohttp 3.14 removed (kevin1024/vcrpy#995; fix proposed
7+
in the unreleased PR #996). A small, idempotent import-time shim,
8+
`ensure_aiohttp_vcr_compat()` in `opencontractserver/utils/vcr_replay.py`,
9+
restores that symbol as an empty mixin so VCR cassette entry works under
10+
aiohttp >=3.14 (this codebase only records/replays httpx LLM cassettes, so the
11+
aiohttp `MockStream` is never instantiated). The shim is applied from the root
12+
`conftest.py` before any test module's top-level `import vcr`, and from
13+
`maybe_vcr_cassette()` for the non-pytest E2E record/replay harness. Delete the
14+
shim and bump `vcrpy` once a release ships the vcrpy#996 fix. Regression test:
15+
`opencontractserver/tests/test_vcr_replay.py::EnsureAiohttpVcrCompatTests`.
16+
Closes #1920.

conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
import pytest
1212
from django import db
1313

14+
from opencontractserver.utils.vcr_replay import ensure_aiohttp_vcr_compat
15+
16+
# Many integration tests record/replay VCR cassettes. vcrpy 8.1.1 imports its
17+
# aiohttp stub lazily when a cassette is entered, and that stub subclasses
18+
# ``aiohttp.streams.AsyncStreamReaderMixin`` — a symbol aiohttp 3.14 removed — at
19+
# module-evaluation time. A fresh CI resolution that picks up aiohttp >= 3.14
20+
# would therefore make every VCR-using test raise AttributeError. Apply the
21+
# compat shim here, at conftest import, so the symbol exists before any test
22+
# runs. See opencontractserver/utils/vcr_replay.py and issue #1920.
23+
ensure_aiohttp_vcr_compat()
24+
1425

1526
@pytest.fixture(scope="session", autouse=True)
1627
def make_create_permissions_xdist_safe():

opencontractserver/tests/test_vcr_replay.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
_VOLATILE_PATTERNS,
3535
_match_llm_body,
3636
_normalize_body,
37+
ensure_aiohttp_vcr_compat,
3738
maybe_vcr_cassette,
3839
)
3940

@@ -322,6 +323,71 @@ def test_replay_mode_yields_active_cassette_when_file_missing(self):
322323
self.assertIsNotNone(ctx)
323324

324325

326+
class EnsureAiohttpVcrCompatTests(TestCase):
327+
"""Regression for issue #1920 / kevin1024/vcrpy#995.
328+
329+
aiohttp 3.14 removed ``aiohttp.streams.AsyncStreamReaderMixin``, which the
330+
pinned vcrpy 8.1.1 aiohttp stub subclasses at module-evaluation time. vcrpy
331+
imports that stub lazily when a cassette is entered, so under aiohttp >= 3.14
332+
every VCR cassette entry raises ``AttributeError``. The compat shim restores
333+
the symbol so cassette entry works again.
334+
"""
335+
336+
def test_mixin_symbol_present_after_shim(self):
337+
try:
338+
from aiohttp import streams
339+
except ModuleNotFoundError: # pragma: no cover - aiohttp always present
340+
self.skipTest("aiohttp not installed")
341+
342+
had_real_symbol = hasattr(streams, "AsyncStreamReaderMixin")
343+
original = getattr(streams, "AsyncStreamReaderMixin", None)
344+
345+
# Keep the test self-contained: restore the module to whatever state it
346+
# was in before this test ran (the shim may add the symbol below).
347+
def _restore() -> None:
348+
if had_real_symbol:
349+
setattr(streams, "AsyncStreamReaderMixin", original)
350+
elif hasattr(streams, "AsyncStreamReaderMixin"):
351+
delattr(streams, "AsyncStreamReaderMixin")
352+
353+
self.addCleanup(_restore)
354+
355+
ensure_aiohttp_vcr_compat()
356+
357+
# Whichever aiohttp is installed, the symbol must exist afterward so
358+
# vcrpy's MockStream class statement can evaluate.
359+
self.assertTrue(hasattr(streams, "AsyncStreamReaderMixin"))
360+
if had_real_symbol:
361+
# Under aiohttp < 3.14 the real mixin must be left untouched.
362+
self.assertIs(getattr(streams, "AsyncStreamReaderMixin"), original)
363+
364+
def test_shim_is_idempotent(self):
365+
# Called twice (conftest + maybe_vcr_cassette both invoke it) must not
366+
# raise or swap the symbol out from under a prior call.
367+
ensure_aiohttp_vcr_compat()
368+
try:
369+
from aiohttp import streams
370+
except ModuleNotFoundError: # pragma: no cover - aiohttp always present
371+
self.skipTest("aiohttp not installed")
372+
first = getattr(streams, "AsyncStreamReaderMixin")
373+
ensure_aiohttp_vcr_compat()
374+
self.assertIs(getattr(streams, "AsyncStreamReaderMixin"), first)
375+
376+
def test_vcr_cassette_entry_works(self):
377+
# The real contract: vcrpy imports its aiohttp stub lazily when a
378+
# cassette is entered (vcr/patch.py builds its patchers). Under aiohttp
379+
# >= 3.14 that stub subclasses the removed AsyncStreamReaderMixin and
380+
# raises AttributeError. With the shim applied, entering an (empty)
381+
# cassette must succeed. (`import vcr` alone does NOT trigger the stub,
382+
# which is why this enters a cassette rather than just importing.)
383+
ensure_aiohttp_vcr_compat()
384+
import vcr
385+
386+
with tempfile.TemporaryDirectory() as td:
387+
with vcr.VCR().use_cassette(os.path.join(td, "regression.yaml")):
388+
pass
389+
390+
325391
class LlmHostsTests(TestCase):
326392
"""Sanity check for the host allowlist."""
327393

opencontractserver/utils/vcr_replay.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,53 @@
4141

4242
logger = logging.getLogger(__name__)
4343

44+
45+
def ensure_aiohttp_vcr_compat() -> None:
46+
"""Make vcrpy 8.1.1's aiohttp stub importable under aiohttp >= 3.14.
47+
48+
vcrpy 8.1.1 (the latest release) defines
49+
``class MockStream(asyncio.StreamReader, streams.AsyncStreamReaderMixin)``
50+
in ``vcr/stubs/aiohttp_stubs.py``. aiohttp 3.14 removed
51+
``aiohttp.streams.AsyncStreamReaderMixin`` (its helpers were folded onto the
52+
stream classes). vcrpy imports that stub lazily — when a cassette is entered
53+
and ``vcr/patch.py`` builds its patchers — so under aiohttp >= 3.14 every VCR
54+
cassette entry raises ``AttributeError`` (issue #1920; upstream
55+
kevin1024/vcrpy#995, fix proposed in the unreleased PR #996).
56+
57+
This codebase only records/replays *httpx* (LLM provider) cassettes — the
58+
aiohttp stub is pulled in incidentally because aiohttp is importable
59+
(transitive via llama-index-core). ``MockStream`` is therefore never
60+
instantiated here, so restoring the removed name as an empty mixin is enough
61+
to let the stub's class body evaluate; none of the mixin's (now-removed)
62+
helper methods are exercised.
63+
64+
Idempotent, and a no-op under aiohttp < 3.14 (where the real mixin is still
65+
present — it is left untouched). Delete this shim, and bump ``vcrpy`` in
66+
requirements/local.txt, once vcrpy ships a release that supports the aiohttp
67+
3.14 stream API.
68+
"""
69+
try:
70+
from aiohttp import streams
71+
except ModuleNotFoundError:
72+
# aiohttp isn't installed → vcrpy won't load its aiohttp stub anyway.
73+
return
74+
75+
if hasattr(streams, "AsyncStreamReaderMixin"):
76+
return
77+
78+
class AsyncStreamReaderMixin:
79+
"""Empty stand-in for the base class aiohttp 3.14 removed.
80+
81+
Only needs to exist so vcrpy's ``MockStream`` class statement can
82+
evaluate; its methods are never called (we never replay aiohttp
83+
cassettes).
84+
"""
85+
86+
# setattr (string name) rather than attribute assignment so mypy does not
87+
# flag a member aiohttp 3.14 removed from the ``streams`` module.
88+
setattr(streams, "AsyncStreamReaderMixin", AsyncStreamReaderMixin)
89+
90+
4491
# These hostnames are the LLM provider endpoints VCR should intercept.
4592
# Other hosts (LlamaParse, embedder microservice, S3) bypass VCR.
4693
_LLM_HOSTS = {"api.openai.com", "api.anthropic.com"}
@@ -201,6 +248,11 @@ def maybe_vcr_cassette() -> Iterator[object | None]:
201248
yield None
202249
return
203250

251+
# vcrpy 8.1.1's aiohttp stub references a symbol aiohttp 3.14 removed;
252+
# restore it before importing vcr so the live E2E record/replay harness
253+
# (which runs outside pytest, where conftest isn't loaded) also works under
254+
# aiohttp >= 3.14. See ensure_aiohttp_vcr_compat above and issue #1920.
255+
ensure_aiohttp_vcr_compat()
204256
import vcr # local import keeps prod paths free of vcr cost
205257

206258
cassette_dir = os.path.dirname(os.path.abspath(cassette_path))

requirements/base.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ typing-extensions==4.* # https://github.com/python/typing_extensions
1212
requests>=2.34.2,<3 # https://requests.readthedocs.io/en/latest/
1313
httpx>=0.28.1,<1 # https://github.com/encode/httpx - async HTTP for agent tools
1414
brotli>=1.2.0 # decoder for httpx Content-Encoding: br responses (e.g. OpenAI API, VCR cassettes). Floor raised to 1.2.0 to pick up the upstream security fix that bounds decompressor output via Decompressor.output_buffer_limit, preventing memory exhaustion from crafted br-encoded payloads.
15-
aiohttp>=3.13,<3.14 # transitive via llama-index-core; cap below 3.14 because aiohttp 3.14.0 removed aiohttp.streams.AsyncStreamReaderMixin, which the pinned vcrpy==8.1.1 aiohttp stub imports at module load (AttributeError on import → whole pytest suite fails). Lift this cap together with a vcrpy bump that supports aiohttp 3.14+.
16-
1715

1816
# Django
1917
# ------------------------------------------------------------------------------

requirements/local.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ pytest-cov==7.1.0 # https://github.com/pytest-dev/pytest-cov
1313
pytest-xdist==3.8.0 # https://github.com/pytest-dev/pytest-xdist (parallel test execution)
1414
djangorestframework-stubs==3.17.0 # https://github.com/typeddjango/djangorestframework-stubs
1515
responses==0.26.1 # https://github.com/getsentry/responses
16+
# vcrpy 8.1.1 is the latest release and is NOT yet compatible with aiohttp
17+
# >=3.14: its aiohttp stub subclasses aiohttp.streams.AsyncStreamReaderMixin at
18+
# import time, a symbol aiohttp 3.14 removed (kevin1024/vcrpy#995; fix proposed
19+
# in the unreleased PR #996). aiohttp is an unpinned transitive dep, so a fresh
20+
# CI resolution picks up 3.14+. ensure_aiohttp_vcr_compat() in
21+
# opencontractserver/utils/vcr_replay.py restores that symbol so vcrpy works
22+
# under aiohttp >=3.14. Bump vcrpy here and delete the shim once a release ships
23+
# the vcrpy#996 fix. See issue #1920.
1624
vcrpy==8.1.1
1725

1826
# Profiling

0 commit comments

Comments
 (0)