|
34 | 34 | _VOLATILE_PATTERNS, |
35 | 35 | _match_llm_body, |
36 | 36 | _normalize_body, |
| 37 | + ensure_aiohttp_vcr_compat, |
37 | 38 | maybe_vcr_cassette, |
38 | 39 | ) |
39 | 40 |
|
@@ -322,6 +323,71 @@ def test_replay_mode_yields_active_cassette_when_file_missing(self): |
322 | 323 | self.assertIsNotNone(ctx) |
323 | 324 |
|
324 | 325 |
|
| 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 | + |
325 | 391 | class LlmHostsTests(TestCase): |
326 | 392 | """Sanity check for the host allowlist.""" |
327 | 393 |
|
|
0 commit comments