From 0f49a2037800d9e48c6f529560324731bf9385f7 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 24 Aug 2025 16:03:18 +0200 Subject: [PATCH 1/4] test: Extend tests for contextvars. --- .../test_async_fixtures_contextvars.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 20bad303..e8634d0c 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -6,7 +6,9 @@ from __future__ import annotations from textwrap import dedent +from typing import Literal +import pytest from pytest import Pytester _prelude = dedent( @@ -213,3 +215,56 @@ async def test(same_var_fixture): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_no_isolation_against_context_changes_in_sync_tests(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """ + import pytest + import pytest_asyncio + from contextvars import ContextVar + + _context_var = ContextVar("my_var") + + def test_sync(): + _context_var.set("new_value") + + @pytest.mark.asyncio + async def test_async(): + assert _context_var.get() == "new_value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +@pytest.mark.parametrize("loop_scope", ("function", "module")) +def test_isolation_against_context_changes_in_async_tests( + pytester: Pytester, loop_scope: Literal["function", "module"] +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + f""" + import pytest + import pytest_asyncio + from contextvars import ContextVar + + _context_var = ContextVar("my_var") + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_async_first(): + _context_var.set("new_value") + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_async_second(): + with pytest.raises(LookupError): + _context_var.get() + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From 24d0f51107bc5b50a0bdcc3899b274f73d4f726a Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 24 Aug 2025 16:10:47 +0200 Subject: [PATCH 2/4] refactor: Move decicion for which event loop a coroutine is run in from synchronizer to test item. This allows getting rid of _get_event_loop_no_warn. --- pytest_asyncio/plugin.py | 47 +++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d5d58aa7..16be8472 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -447,7 +447,14 @@ def _can_substitute(item: Function) -> bool: return inspect.iscoroutinefunction(func) def runtest(self) -> None: - synchronized_obj = wrap_in_sync(self.obj) + marker = self.get_closest_marker("asyncio") + assert marker is not None + default_loop_scope = _get_default_test_loop_scope(self.config) + loop_scope = _get_marked_loop_scope(marker, default_loop_scope) + runner_fixture_id = f"_{loop_scope}_scoped_runner" + runner = self._request.getfixturevalue(runner_fixture_id) + context = contextvars.copy_context() + synchronized_obj = wrap_in_sync(self.obj, runner, context) with MonkeyPatch.context() as c: c.setattr(self, "obj", synchronized_obj) super().runtest() @@ -489,7 +496,14 @@ def _can_substitute(item: Function) -> bool: ) def runtest(self) -> None: - synchronized_obj = wrap_in_sync(self.obj) + marker = self.get_closest_marker("asyncio") + assert marker is not None + default_loop_scope = _get_default_test_loop_scope(self.config) + loop_scope = _get_marked_loop_scope(marker, default_loop_scope) + runner_fixture_id = f"_{loop_scope}_scoped_runner" + runner = self._request.getfixturevalue(runner_fixture_id) + context = contextvars.copy_context() + synchronized_obj = wrap_in_sync(self.obj, runner, context=context) with MonkeyPatch.context() as c: c.setattr(self, "obj", synchronized_obj) super().runtest() @@ -511,7 +525,14 @@ def _can_substitute(item: Function) -> bool: ) def runtest(self) -> None: - synchronized_obj = wrap_in_sync(self.obj.hypothesis.inner_test) + marker = self.get_closest_marker("asyncio") + assert marker is not None + default_loop_scope = _get_default_test_loop_scope(self.config) + loop_scope = _get_marked_loop_scope(marker, default_loop_scope) + runner_fixture_id = f"_{loop_scope}_scoped_runner" + runner = self._request.getfixturevalue(runner_fixture_id) + context = contextvars.copy_context() + synchronized_obj = wrap_in_sync(self.obj.hypothesis.inner_test, runner, context) with MonkeyPatch.context() as c: c.setattr(self.obj.hypothesis, "inner_test", synchronized_obj) super().runtest() @@ -653,27 +674,19 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: def wrap_in_sync( - func: Callable[..., Awaitable[Any]], + func: Callable[..., CoroutineType], + runner: asyncio.Runner, + context: contextvars.Context, ): """ - Return a sync wrapper around an async function executing it in the - current event loop. + Return a sync wrapper around a coroutine executing it in the + specified runner and context. """ @functools.wraps(func) def inner(*args, **kwargs): coro = func(*args, **kwargs) - _loop = _get_event_loop_no_warn() - task = asyncio.ensure_future(coro, loop=_loop) - try: - _loop.run_until_complete(task) - except BaseException: - # run_until_complete doesn't get the result from exceptions - # that are not subclasses of `Exception`. Consume all - # exceptions to prevent asyncio's warning from logging. - if task.done() and not task.cancelled(): - task.exception() - raise + runner.run(coro, context=context) return inner From e60c10ebdfbb10c30716b8ba66eee8e793aed81b Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 24 Aug 2025 16:28:04 +0200 Subject: [PATCH 3/4] refactor: Deduplicate runtest logic for PytestAsyncioFunction subclasses. --- pytest_asyncio/plugin.py | 63 +++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 16be8472..b27a492f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -437,15 +437,6 @@ def _can_substitute(item: Function) -> bool: """Returns whether the specified function can be replaced by this class""" raise NotImplementedError() - -class Coroutine(PytestAsyncioFunction): - """Pytest item created by a coroutine""" - - @staticmethod - def _can_substitute(item: Function) -> bool: - func = item.obj - return inspect.iscoroutinefunction(func) - def runtest(self) -> None: marker = self.get_closest_marker("asyncio") assert marker is not None @@ -454,11 +445,33 @@ def runtest(self) -> None: runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = self._request.getfixturevalue(runner_fixture_id) context = contextvars.copy_context() - synchronized_obj = wrap_in_sync(self.obj, runner, context) + synchronized_obj = wrap_in_sync( + getattr(*self._synchronization_target_attr), runner, context + ) with MonkeyPatch.context() as c: - c.setattr(self, "obj", synchronized_obj) + c.setattr(*self._synchronization_target_attr, synchronized_obj) super().runtest() + @property + def _synchronization_target_attr(self) -> tuple[object, str]: + """ + Return the coroutine that needs to be synchronized during the test run. + + This method is inteded to be overwritten by subclasses when they need to apply + the coroutine synchronizer to a value that's different from self.obj + e.g. the AsyncHypothesisTest subclass. + """ + return self, "obj" + + +class Coroutine(PytestAsyncioFunction): + """Pytest item created by a coroutine""" + + @staticmethod + def _can_substitute(item: Function) -> bool: + func = item.obj + return inspect.iscoroutinefunction(func) + class AsyncGenerator(PytestAsyncioFunction): """Pytest item created by an asynchronous generator""" @@ -495,19 +508,6 @@ def _can_substitute(item: Function) -> bool: func.__func__ ) - def runtest(self) -> None: - marker = self.get_closest_marker("asyncio") - assert marker is not None - default_loop_scope = _get_default_test_loop_scope(self.config) - loop_scope = _get_marked_loop_scope(marker, default_loop_scope) - runner_fixture_id = f"_{loop_scope}_scoped_runner" - runner = self._request.getfixturevalue(runner_fixture_id) - context = contextvars.copy_context() - synchronized_obj = wrap_in_sync(self.obj, runner, context=context) - with MonkeyPatch.context() as c: - c.setattr(self, "obj", synchronized_obj) - super().runtest() - class AsyncHypothesisTest(PytestAsyncioFunction): """ @@ -524,18 +524,9 @@ def _can_substitute(item: Function) -> bool: and inspect.iscoroutinefunction(func.hypothesis.inner_test) ) - def runtest(self) -> None: - marker = self.get_closest_marker("asyncio") - assert marker is not None - default_loop_scope = _get_default_test_loop_scope(self.config) - loop_scope = _get_marked_loop_scope(marker, default_loop_scope) - runner_fixture_id = f"_{loop_scope}_scoped_runner" - runner = self._request.getfixturevalue(runner_fixture_id) - context = contextvars.copy_context() - synchronized_obj = wrap_in_sync(self.obj.hypothesis.inner_test, runner, context) - with MonkeyPatch.context() as c: - c.setattr(self.obj.hypothesis, "inner_test", synchronized_obj) - super().runtest() + @property + def _synchronization_target_attr(self) -> tuple[object, str]: + return self.obj.hypothesis, "inner_test" # The function name needs to start with "pytest_" From 695d1e6c9168c63995a2d62182510850507ef3fb Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 24 Aug 2025 16:32:45 +0200 Subject: [PATCH 4/4] refactor: Rename wrap_in_sync to _synchronize_coroutine. --- pytest_asyncio/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index b27a492f..410e1206 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -445,7 +445,7 @@ def runtest(self) -> None: runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = self._request.getfixturevalue(runner_fixture_id) context = contextvars.copy_context() - synchronized_obj = wrap_in_sync( + synchronized_obj = _synchronize_coroutine( getattr(*self._synchronization_target_attr), runner, context ) with MonkeyPatch.context() as c: @@ -664,7 +664,7 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: return None -def wrap_in_sync( +def _synchronize_coroutine( func: Callable[..., CoroutineType], runner: asyncio.Runner, context: contextvars.Context,