Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

from opencontractserver.utils.vcr_replay import ensure_aiohttp_vcr_compat

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


@pytest.fixture(scope="session", autouse=True)
def make_create_permissions_xdist_safe():
Expand Down
66 changes: 66 additions & 0 deletions opencontractserver/tests/test_vcr_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
_VOLATILE_PATTERNS,
_match_llm_body,
_normalize_body,
ensure_aiohttp_vcr_compat,
maybe_vcr_cassette,
)

Expand Down Expand Up @@ -322,6 +323,71 @@ def test_replay_mode_yields_active_cassette_when_file_missing(self):
self.assertIsNotNone(ctx)


class EnsureAiohttpVcrCompatTests(TestCase):
"""Regression for issue #1920 / kevin1024/vcrpy#995.

aiohttp 3.14 removed ``aiohttp.streams.AsyncStreamReaderMixin``, which the
pinned vcrpy 8.1.1 aiohttp stub subclasses at module-evaluation time. vcrpy
imports that stub lazily when a cassette is entered, so under aiohttp >= 3.14
every VCR cassette entry raises ``AttributeError``. The compat shim restores
the symbol so cassette entry works again.
"""

def test_mixin_symbol_present_after_shim(self):
try:
from aiohttp import streams
except ModuleNotFoundError: # pragma: no cover - aiohttp always present
self.skipTest("aiohttp not installed")

had_real_symbol = hasattr(streams, "AsyncStreamReaderMixin")
original = getattr(streams, "AsyncStreamReaderMixin", None)

# Keep the test self-contained: restore the module to whatever state it
# was in before this test ran (the shim may add the symbol below).
def _restore() -> None:
if had_real_symbol:
setattr(streams, "AsyncStreamReaderMixin", original)
elif hasattr(streams, "AsyncStreamReaderMixin"):
delattr(streams, "AsyncStreamReaderMixin")

self.addCleanup(_restore)

ensure_aiohttp_vcr_compat()

# Whichever aiohttp is installed, the symbol must exist afterward so
# vcrpy's MockStream class statement can evaluate.
self.assertTrue(hasattr(streams, "AsyncStreamReaderMixin"))
if had_real_symbol:
# Under aiohttp < 3.14 the real mixin must be left untouched.
self.assertIs(getattr(streams, "AsyncStreamReaderMixin"), original)

def test_shim_is_idempotent(self):
# Called twice (conftest + maybe_vcr_cassette both invoke it) must not
# raise or swap the symbol out from under a prior call.
ensure_aiohttp_vcr_compat()
try:
from aiohttp import streams
except ModuleNotFoundError: # pragma: no cover - aiohttp always present
self.skipTest("aiohttp not installed")
first = getattr(streams, "AsyncStreamReaderMixin")
ensure_aiohttp_vcr_compat()
self.assertIs(getattr(streams, "AsyncStreamReaderMixin"), first)

def test_vcr_cassette_entry_works(self):
# The real contract: vcrpy imports its aiohttp stub lazily when a
# cassette is entered (vcr/patch.py builds its patchers). Under aiohttp
# >= 3.14 that stub subclasses the removed AsyncStreamReaderMixin and
# raises AttributeError. With the shim applied, entering an (empty)
# cassette must succeed. (`import vcr` alone does NOT trigger the stub,
# which is why this enters a cassette rather than just importing.)
ensure_aiohttp_vcr_compat()
import vcr

with tempfile.TemporaryDirectory() as td:
with vcr.VCR().use_cassette(os.path.join(td, "regression.yaml")):
pass


class LlmHostsTests(TestCase):
"""Sanity check for the host allowlist."""

Expand Down
52 changes: 52 additions & 0 deletions opencontractserver/utils/vcr_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,53 @@

logger = logging.getLogger(__name__)


def ensure_aiohttp_vcr_compat() -> None:
"""Make vcrpy 8.1.1's aiohttp stub importable under aiohttp >= 3.14.

vcrpy 8.1.1 (the latest release) defines
``class MockStream(asyncio.StreamReader, streams.AsyncStreamReaderMixin)``
in ``vcr/stubs/aiohttp_stubs.py``. aiohttp 3.14 removed
``aiohttp.streams.AsyncStreamReaderMixin`` (its helpers were folded onto the
stream classes). vcrpy imports that stub lazily — when a cassette is entered
and ``vcr/patch.py`` builds its patchers — so under aiohttp >= 3.14 every VCR
cassette entry raises ``AttributeError`` (issue #1920; upstream
kevin1024/vcrpy#995, fix proposed in the unreleased PR #996).

This codebase only records/replays *httpx* (LLM provider) cassettes — the
aiohttp stub is pulled in incidentally because aiohttp is importable
(transitive via llama-index-core). ``MockStream`` is therefore never
instantiated here, so restoring the removed name as an empty mixin is enough
to let the stub's class body evaluate; none of the mixin's (now-removed)
helper methods are exercised.

Idempotent, and a no-op under aiohttp < 3.14 (where the real mixin is still
present — it is left untouched). Delete this shim, and bump ``vcrpy`` in
requirements/local.txt, once vcrpy ships a release that supports the aiohttp
3.14 stream API.
"""
try:
from aiohttp import streams
except ModuleNotFoundError:
# aiohttp isn't installed → vcrpy won't load its aiohttp stub anyway.
return

if hasattr(streams, "AsyncStreamReaderMixin"):
return

class AsyncStreamReaderMixin:
"""Empty stand-in for the base class aiohttp 3.14 removed.

Only needs to exist so vcrpy's ``MockStream`` class statement can
evaluate; its methods are never called (we never replay aiohttp
cassettes).
"""

# setattr (string name) rather than attribute assignment so mypy does not
# flag a member aiohttp 3.14 removed from the ``streams`` module.
setattr(streams, "AsyncStreamReaderMixin", AsyncStreamReaderMixin)


# These hostnames are the LLM provider endpoints VCR should intercept.
# Other hosts (LlamaParse, embedder microservice, S3) bypass VCR.
_LLM_HOSTS = {"api.openai.com", "api.anthropic.com"}
Expand Down Expand Up @@ -201,6 +248,11 @@ def maybe_vcr_cassette() -> Iterator[object | None]:
yield None
return

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

cassette_dir = os.path.dirname(os.path.abspath(cassette_path))
Expand Down
2 changes: 0 additions & 2 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ typing-extensions==4.* # https://github.com/python/typing_extensions
requests>=2.34.2,<3 # https://requests.readthedocs.io/en/latest/
httpx>=0.28.1,<1 # https://github.com/encode/httpx - async HTTP for agent tools
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.
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+.


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

# Profiling
Expand Down