From e8546bd1cdac8922d5d43efd6e0be334488f9244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 12 Oct 2024 23:31:17 +0300 Subject: [PATCH] Rebind instance method fixtures to the same instance as the test (#807) Fixes #633. --------- Co-authored-by: Thomas Grainger (cherry picked from commit 65ef48a7370f9ae870d42b9aa59e3a9840da3157) --- docs/versionhistory.rst | 3 ++ src/anyio/pytest_plugin.py | 47 +++++++++++++++++++----- tests/test_pytest_plugin.py | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index c71002b8..3a0d2e0d 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -7,6 +7,9 @@ This library adheres to `Semantic Versioning 2.0 `_. - Fixed acquring a lock twice in the same task on asyncio hanging instead of raising a ``RuntimeError`` (`#798 `_) +- Fixed an async fixture's ``self`` being different than the test's ``self`` in + class-based tests (`#633 `_) + (PR by @agronholm and @graingert) **4.6.0** diff --git a/src/anyio/pytest_plugin.py b/src/anyio/pytest_plugin.py index 558c72ec..5658f32b 100644 --- a/src/anyio/pytest_plugin.py +++ b/src/anyio/pytest_plugin.py @@ -1,13 +1,14 @@ from __future__ import annotations import sys -from collections.abc import Iterator +from collections.abc import Generator, Iterator from contextlib import ExitStack, contextmanager -from inspect import isasyncgenfunction, iscoroutinefunction +from inspect import isasyncgenfunction, iscoroutinefunction, ismethod from typing import Any, Dict, Tuple, cast import pytest import sniffio +from _pytest.fixtures import SubRequest from _pytest.outcomes import Exit from ._core._eventloop import get_all_backends, get_async_backend @@ -70,28 +71,56 @@ def pytest_configure(config: Any) -> None: ) -def pytest_fixture_setup(fixturedef: Any, request: Any) -> None: - def wrapper(*args, anyio_backend, **kwargs): # type: ignore[no-untyped-def] +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[Any]: + def wrapper( + *args: Any, anyio_backend: Any, request: SubRequest, **kwargs: Any + ) -> Any: + # Rebind any fixture methods to the request instance + if ( + request.instance + and ismethod(func) + and type(func.__self__) is type(request.instance) + ): + local_func = func.__func__.__get__(request.instance) + else: + local_func = func + backend_name, backend_options = extract_backend_and_options(anyio_backend) if has_backend_arg: kwargs["anyio_backend"] = anyio_backend + if has_request_arg: + kwargs["request"] = anyio_backend + with get_runner(backend_name, backend_options) as runner: - if isasyncgenfunction(func): - yield from runner.run_asyncgen_fixture(func, kwargs) + if isasyncgenfunction(local_func): + yield from runner.run_asyncgen_fixture(local_func, kwargs) else: - yield runner.run_fixture(func, kwargs) + yield runner.run_fixture(local_func, kwargs) # Only apply this to coroutine functions and async generator functions in requests # that involve the anyio_backend fixture func = fixturedef.func if isasyncgenfunction(func) or iscoroutinefunction(func): if "anyio_backend" in request.fixturenames: - has_backend_arg = "anyio_backend" in fixturedef.argnames fixturedef.func = wrapper - if not has_backend_arg: + original_argname = fixturedef.argnames + + if not (has_backend_arg := "anyio_backend" in fixturedef.argnames): fixturedef.argnames += ("anyio_backend",) + if not (has_request_arg := "request" in fixturedef.argnames): + fixturedef.argnames += ("request",) + + try: + return (yield) + finally: + fixturedef.func = func + fixturedef.argnames = original_argname + + return (yield) + @pytest.hookimpl(tryfirst=True) def pytest_pycollect_makeitem(collector: Any, name: Any, obj: Any) -> None: diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 1aa8911e..f1b20f91 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -468,3 +468,76 @@ async def test_anyio_mark_first(): ) testdir.runpytest_subprocess(*pytest_args, timeout=3) + + +def test_async_fixture_in_test_class(testdir: Pytester) -> None: + # Regression test for #633 + testdir.makepyfile( + """ + import pytest + + + class TestAsyncFixtureMethod: + is_same_instance = False + + @pytest.fixture(autouse=True) + async def async_fixture_method(self): + self.is_same_instance = True + + @pytest.mark.anyio + async def test_async_fixture_method(self): + assert self.is_same_instance + """ + ) + + result = testdir.runpytest_subprocess(*pytest_args) + result.assert_outcomes(passed=len(get_all_backends())) + + +def test_asyncgen_fixture_in_test_class(testdir: Pytester) -> None: + # Regression test for #633 + testdir.makepyfile( + """ + import pytest + + + class TestAsyncFixtureMethod: + is_same_instance = False + + @pytest.fixture(autouse=True) + async def async_fixture_method(self): + self.is_same_instance = True + yield + + @pytest.mark.anyio + async def test_async_fixture_method(self): + assert self.is_same_instance + """ + ) + + result = testdir.runpytest_subprocess(*pytest_args) + result.assert_outcomes(passed=len(get_all_backends())) + + +def test_anyio_fixture_adoption_does_not_persist(testdir: Pytester) -> None: + testdir.makepyfile( + """ + import inspect + import pytest + + @pytest.fixture + async def fixt(): + return 1 + + @pytest.mark.anyio + async def test_fixt(fixt): + assert fixt == 1 + + def test_no_mark(fixt): + assert inspect.iscoroutine(fixt) + fixt.close() + """ + ) + + result = testdir.runpytest(*pytest_args) + result.assert_outcomes(passed=len(get_all_backends()) + 1)