From 4573c26d6565d76f0bfe55e1dad18a0228a1b934 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 14:22:11 +0200 Subject: [PATCH 1/6] refactor: Synchronization wrappers of test items are temporary. Previously, synchronizers modified the obj attribute of a function item permanently. This could possibly result in multiple levels of wrapping. This patch restores the original function object after the test finished. --- pytest_asyncio/plugin.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 7383c643..77beb892 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -451,11 +451,10 @@ def _can_substitute(item: Function) -> bool: return inspect.iscoroutinefunction(func) def runtest(self) -> None: - self.obj = wrap_in_sync( - # https://github.com/pytest-dev/pytest-asyncio/issues/596 - self.obj, # type: ignore[has-type] - ) - super().runtest() + synchronized_obj = wrap_in_sync(self.obj) + with MonkeyPatch.context() as c: + c.setattr(self, "obj", synchronized_obj) + super().runtest() class AsyncGenerator(PytestAsyncioFunction): @@ -494,11 +493,10 @@ def _can_substitute(item: Function) -> bool: ) def runtest(self) -> None: - self.obj = wrap_in_sync( - # https://github.com/pytest-dev/pytest-asyncio/issues/596 - self.obj, # type: ignore[has-type] - ) - super().runtest() + synchronized_obj = wrap_in_sync(self.obj) + with MonkeyPatch.context() as c: + c.setattr(self, "obj", synchronized_obj) + super().runtest() class AsyncHypothesisTest(PytestAsyncioFunction): @@ -517,10 +515,10 @@ def _can_substitute(item: Function) -> bool: ) def runtest(self) -> None: - self.obj.hypothesis.inner_test = wrap_in_sync( - self.obj.hypothesis.inner_test, - ) - super().runtest() + synchronized_obj = wrap_in_sync(self.obj.hypothesis.inner_test) + with MonkeyPatch.context() as c: + c.setattr(self.obj.hypothesis, "inner_test", synchronized_obj) + super().runtest() # The function name needs to start with "pytest_" From 38a80473215a9b20321f90feecd0cd17959c6e4e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 14:23:54 +0200 Subject: [PATCH 2/6] refactor: Remove obsolete logic to prevent double wrapping in wrap_in_sync. --- pytest_asyncio/plugin.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 77beb892..556badaf 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -689,12 +689,6 @@ def wrap_in_sync( Return a sync wrapper around an async function executing it in the current event loop. """ - # if the function is already wrapped, we rewrap using the original one - # not using __wrapped__ because the original function may already be - # a wrapped one - raw_func = getattr(func, "_raw_test_func", None) - if raw_func is not None: - func = raw_func @functools.wraps(func) def inner(*args, **kwargs): @@ -711,7 +705,6 @@ def inner(*args, **kwargs): task.exception() raise - inner._raw_test_func = func # type: ignore[attr-defined] return inner From 9a71e4dff4fedb97bf7a20f6ccf2c142e531203e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 15:17:17 +0200 Subject: [PATCH 3/6] refactor: Replace custom fixture func rebinding with pytest function --- pytest_asyncio/plugin.py | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 556badaf..cffb6920 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -33,6 +33,7 @@ import pluggy import pytest +from _pytest.fixtures import resolve_fixture_function from _pytest.scope import Scope from pytest import ( Config, @@ -60,7 +61,6 @@ from backports.asyncio.runner import Runner _ScopeName = Literal["session", "package", "module", "class", "function"] -_T = TypeVar("_T") _R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) _P = ParamSpec("_P") FixtureFunction = Callable[_P, _R] @@ -234,12 +234,15 @@ def pytest_report_header(config: Config) -> list[str]: ] -def _fixture_synchronizer(fixturedef: FixtureDef, runner: Runner) -> Callable: +def _fixture_synchronizer( + fixturedef: FixtureDef, runner: Runner, request: FixtureRequest +) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" + fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - return _wrap_asyncgen_fixture(fixturedef.func, runner) + return _wrap_asyncgen_fixture(fixture_function, runner) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): - return _wrap_async_fixture(fixturedef.func, runner) + return _wrap_async_fixture(fixture_function, runner) # type: ignore[arg-type] else: return fixturedef.func @@ -256,22 +259,6 @@ def _add_kwargs( return ret -def _perhaps_rebind_fixture_func(func: _T, instance: Any | None) -> _T: - if instance is not None: - # The fixture needs to be bound to the actual request.instance - # so it is bound to the same object as the test method. - unbound, cls = func, None - try: - unbound, cls = func.__func__, type(func.__self__) # type: ignore - except AttributeError: - pass - # Only if the fixture was bound before to an instance of - # the same type. - if cls is not None and isinstance(instance, cls): - func = unbound.__get__(instance) # type: ignore - return func - - AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") @@ -290,8 +277,9 @@ def _asyncgen_fixture_wrapper( *args: AsyncGenFixtureParams.args, **kwargs: AsyncGenFixtureParams.kwargs, ): - func = _perhaps_rebind_fixture_func(fixture_function, request.instance) - gen_obj = func(*args, **_add_kwargs(func, kwargs, request)) + gen_obj = fixture_function( + *args, **_add_kwargs(fixture_function, kwargs, request) + ) async def setup(): res = await gen_obj.__anext__() # type: ignore[union-attr] @@ -342,10 +330,10 @@ def _async_fixture_wrapper( *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs, ): - func = _perhaps_rebind_fixture_func(fixture_function, request.instance) - async def setup(): - res = await func(*args, **_add_kwargs(func, kwargs, request)) + res = await fixture_function( + *args, **_add_kwargs(fixture_function, kwargs, request) + ) return res context = contextvars.copy_context() @@ -746,7 +734,7 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: ) runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = request.getfixturevalue(runner_fixture_id) - synchronizer = _fixture_synchronizer(fixturedef, runner) + synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: if "request" not in fixturedef.argnames: From bbdf03ef44ed5b9dcfef34ef859a03a74255584d Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 15:31:19 +0200 Subject: [PATCH 4/6] refactor: Forward fixture request into synchronizers instead of modifying fixture argnames. --- pytest_asyncio/plugin.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index cffb6920..4dba7a2b 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -51,9 +51,9 @@ ) if sys.version_info >= (3, 10): - from typing import Concatenate, ParamSpec + from typing import ParamSpec else: - from typing_extensions import Concatenate, ParamSpec + from typing_extensions import ParamSpec if sys.version_info >= (3, 11): from asyncio import Runner @@ -240,9 +240,9 @@ def _fixture_synchronizer( """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - return _wrap_asyncgen_fixture(fixture_function, runner) # type: ignore[arg-type] + return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): - return _wrap_async_fixture(fixture_function, runner) # type: ignore[arg-type] + return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] else: return fixturedef.func @@ -268,18 +268,14 @@ def _wrap_asyncgen_fixture( AsyncGenFixtureParams, AsyncGeneratorType[AsyncGenFixtureYieldType, Any] ], runner: Runner, -) -> Callable[ - Concatenate[FixtureRequest, AsyncGenFixtureParams], AsyncGenFixtureYieldType -]: + request: FixtureRequest, +) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( - request: FixtureRequest, *args: AsyncGenFixtureParams.args, **kwargs: AsyncGenFixtureParams.kwargs, ): - gen_obj = fixture_function( - *args, **_add_kwargs(fixture_function, kwargs, request) - ) + gen_obj = fixture_function(*args, **kwargs) async def setup(): res = await gen_obj.__anext__() # type: ignore[union-attr] @@ -322,18 +318,16 @@ def _wrap_async_fixture( AsyncFixtureParams, CoroutineType[Any, Any, AsyncFixtureReturnType] ], runner: Runner, -) -> Callable[Concatenate[FixtureRequest, AsyncFixtureParams], AsyncFixtureReturnType]: + request: FixtureRequest, +) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: @functools.wraps(fixture_function) # type: ignore[arg-type] def _async_fixture_wrapper( - request: FixtureRequest, *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs, ): async def setup(): - res = await fixture_function( - *args, **_add_kwargs(fixture_function, kwargs, request) - ) + res = await fixture_function(*args, **kwargs) return res context = contextvars.copy_context() @@ -737,8 +731,6 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: - if "request" not in fixturedef.argnames: - c.setattr(fixturedef, "argnames", (*fixturedef.argnames, "request")) c.setattr(fixturedef, "func", synchronizer) hook_result = yield return hook_result From abf5d7c1d9dd93478791d464c55dc7ef35226eb7 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 15:48:38 +0200 Subject: [PATCH 5/6] refactor: Remove obsolete function _add_kwargs --- pytest_asyncio/plugin.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 4dba7a2b..e216d075 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -247,18 +247,6 @@ def _fixture_synchronizer( return fixturedef.func -def _add_kwargs( - func: Callable[..., Any], - kwargs: dict[str, Any], - request: FixtureRequest, -) -> dict[str, Any]: - sig = inspect.signature(func) - ret = kwargs.copy() - if "request" in sig.parameters: - ret["request"] = request - return ret - - AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") From 3baa4c611f9753732e7efbbdafc1090dda6da9f1 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 24 Jun 2025 16:05:20 +0200 Subject: [PATCH 6/6] fix: Remove pytest_generate_tests. This also fixes an issue that could cause the warning for the deprecated *scope* argument to the asyncio marker to be reported multiple times. --- changelog.d/+b22c903a.fixed.rst | 1 + pytest_asyncio/plugin.py | 27 --------------------------- tests/markers/test_function_scope.py | 2 +- 3 files changed, 2 insertions(+), 28 deletions(-) create mode 100644 changelog.d/+b22c903a.fixed.rst diff --git a/changelog.d/+b22c903a.fixed.rst b/changelog.d/+b22c903a.fixed.rst new file mode 100644 index 00000000..5af4f6e7 --- /dev/null +++ b/changelog.d/+b22c903a.fixed.rst @@ -0,0 +1 @@ +An error that could cause duplicate warnings to be issued diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index e216d075..9bfcfc64 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -42,7 +42,6 @@ Function, Item, Mark, - Metafunc, MonkeyPatch, Parser, PytestCollectionWarning, @@ -547,32 +546,6 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No _set_event_loop(old_loop) -@pytest.hookimpl(tryfirst=True) -def pytest_generate_tests(metafunc: Metafunc) -> None: - marker = metafunc.definition.get_closest_marker("asyncio") - if not marker: - return - default_loop_scope = _get_default_test_loop_scope(metafunc.config) - loop_scope = _get_marked_loop_scope(marker, default_loop_scope) - runner_fixture_id = f"_{loop_scope}_scoped_runner" - # This specific fixture name may already be in metafunc.argnames, if this - # test indirectly depends on the fixture. For example, this is the case - # when the test depends on an async fixture, both of which share the same - # event loop fixture mark. - if runner_fixture_id in metafunc.fixturenames: - return - fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") - assert fixturemanager is not None - # Add the scoped event loop fixture to Metafunc's list of fixture names and - # fixturedefs and leave the actual parametrization to pytest - # The fixture needs to be appended to avoid messing up the fixture evaluation - # order - metafunc.fixturenames.append(runner_fixture_id) - metafunc._arg2fixturedefs[runner_fixture_id] = fixturemanager._arg2fixturedefs[ - runner_fixture_id - ] - - def _get_event_loop_policy() -> AbstractEventLoopPolicy: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py index f750ba58..feb6bae3 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -88,7 +88,7 @@ async def test_warns(): ) ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=2) + result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines("*DeprecationWarning*")