From 2eefc699b52131b7666eb6b9b12ee57a8510a03c Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 20 Sep 2023 15:57:28 +0200 Subject: [PATCH 01/13] [feat] Introduce the asyncio_event_loop mark which provides a class-scoped asyncio event loop when a class has the mark. Signed-off-by: Michael Seifert --- docs/source/reference/markers.rst | 52 +++++++++++++++++++ pytest_asyncio/plugin.py | 33 ++++++++++++ tests/markers/test_class_marker.py | 81 ++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index eb89592c..d3bc291c 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -30,5 +30,57 @@ In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is automatically to *async* test functions. +``pytest.mark.asyncio_event_loop`` +================================== +Test classes with this mark provide a class-scoped asyncio event loop. + +This functionality is orthogonal to the `asyncio` mark. +That means the presence of this mark does not imply that async test functions inside the class are collected by pytest-asyncio. +The collection happens automatically in `auto` mode. +However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions. + +The following code example uses the `asyncio_event_loop` mark to provide a shared event loop for all tests in `TestClassScopedLoop`: + +.. code-block:: python + + import asyncio + + import pytest + + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio + async def test_remember_loop(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + +In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: + +.. code-block:: python + + import asyncio + + import pytest + + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + + + + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c07dfced..d0ff0c7c 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -26,6 +26,7 @@ ) import pytest +from _pytest.mark.structures import get_unpacked_marks from pytest import ( Config, FixtureRequest, @@ -176,6 +177,11 @@ def pytest_configure(config: Config) -> None: "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) + config.addinivalue_line( + "markers", + "asyncio_event_loop: " + "Provides an asyncio event loop in the scope of the marked test class", + ) @pytest.hookimpl(tryfirst=True) @@ -339,6 +345,33 @@ def pytest_pycollect_makeitem( return None +@pytest.hookimpl +def pytest_collectstart(collector: pytest.Collector): + if not isinstance(collector, pytest.Class): + return + # pytest.Collector.own_markers is empty at this point, + # so we rely on _pytest.mark.structures.get_unpacked_marks + marks = get_unpacked_marks(collector.obj, consider_mro=True) + for mark in marks: + if not mark.name == "asyncio_event_loop": + continue + + @pytest.fixture( + scope="class", + name="event_loop", + ) + def scoped_event_loop(cls) -> Iterator[asyncio.AbstractEventLoop]: + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + # @pytest.fixture does not register the fixture anywhere, so pytest doesn't + # know it exists. We work around this by attaching the fixture function to the + # collected Python class, where it will be picked up by pytest.Class.collect() + collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop + break + + def pytest_collection_modifyitems( session: Session, config: Config, items: List[Item] ) -> None: diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index d46c3af7..19645747 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -1,5 +1,6 @@ """Test if pytestmark works when defined on a class.""" import asyncio +from textwrap import dedent import pytest @@ -23,3 +24,83 @@ async def inc(): @pytest.fixture def sample_fixture(): return None + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio + async def test_remember_loop(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + class TestSuperClassWithMark: + pass + + class TestWithoutMark(TestSuperClassWithMark): + loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio + async def test_remember_loop(self): + TestWithoutMark.loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestWithoutMark.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From be36ce68cc16143aa5c709bec2ed06aa9cbdc516 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 26 Sep 2023 15:03:09 +0200 Subject: [PATCH 02/13] [refactor] Existing tests in test_module_marker are executed with pytest.Pytester to avoid applying pytestmark to subsequent tests in the test module. Signed-off-by: Michael Seifert --- tests/markers/test_module_marker.py | 65 +++++++++++++++++------------ 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index 2f69dbc9..c870edb7 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -1,39 +1,52 @@ -"""Test if pytestmark works when defined in a module.""" -import asyncio +from textwrap import dedent -import pytest +from pytest import Pytester -pytestmark = pytest.mark.asyncio +def test_asyncio_mark_works_on_module_level(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio -class TestPyTestMark: - async def test_is_asyncio(self, event_loop, sample_fixture): - assert asyncio.get_event_loop() + import pytest - counter = 1 + pytestmark = pytest.mark.asyncio - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) - await asyncio.ensure_future(inc()) - assert counter == 2 + class TestPyTestMark: + async def test_is_asyncio(self, event_loop, sample_fixture): + assert asyncio.get_event_loop() + counter = 1 -async def test_is_asyncio(event_loop, sample_fixture): - assert asyncio.get_event_loop() - counter = 1 + async def inc(): + nonlocal counter + counter += 1 + await asyncio.sleep(0) - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) + await asyncio.ensure_future(inc()) + assert counter == 2 - await asyncio.ensure_future(inc()) - assert counter == 2 + async def test_is_asyncio(event_loop, sample_fixture): + assert asyncio.get_event_loop() + counter = 1 -@pytest.fixture -def sample_fixture(): - return None + async def inc(): + nonlocal counter + counter += 1 + await asyncio.sleep(0) + + await asyncio.ensure_future(inc()) + assert counter == 2 + + + @pytest.fixture + def sample_fixture(): + return None + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From 0c92628826d1a867361afbca5b3b0ee42100e2eb Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 3 Oct 2023 12:54:47 +0200 Subject: [PATCH 03/13] [feat] The asyncio_event_loop mark provides a module-scoped asyncio event loop when a module has the mark. Signed-off-by: Michael Seifert --- docs/source/reference/markers.rst | 29 ++++++++++++- pytest_asyncio/plugin.py | 12 ++++-- tests/markers/test_module_marker.py | 63 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index d3bc291c..9c4edc28 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -32,10 +32,10 @@ automatically to *async* test functions. ``pytest.mark.asyncio_event_loop`` ================================== -Test classes with this mark provide a class-scoped asyncio event loop. +Test classes or modules with this mark provide a class-scoped or module-scoped asyncio event loop. This functionality is orthogonal to the `asyncio` mark. -That means the presence of this mark does not imply that async test functions inside the class are collected by pytest-asyncio. +That means the presence of this mark does not imply that async test functions inside the class or module are collected by pytest-asyncio. The collection happens automatically in `auto` mode. However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions. @@ -79,8 +79,33 @@ In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop +Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` mark to the module: +.. code-block:: python + + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + loop: asyncio.AbstractEventLoop + async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + + async def test_this_runs_in_same_loop(): + global loop + assert asyncio.get_running_loop() is loop + + + class TestClassA: + async def test_this_runs_in_same_loop(self): + global loop + assert asyncio.get_running_loop() is loop + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d0ff0c7c..794a3088 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -180,7 +180,8 @@ def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", "asyncio_event_loop: " - "Provides an asyncio event loop in the scope of the marked test class", + "Provides an asyncio event loop in the scope of the marked test " + "class or module", ) @@ -347,7 +348,7 @@ def pytest_pycollect_makeitem( @pytest.hookimpl def pytest_collectstart(collector: pytest.Collector): - if not isinstance(collector, pytest.Class): + if not isinstance(collector, (pytest.Class, pytest.Module)): return # pytest.Collector.own_markers is empty at this point, # so we rely on _pytest.mark.structures.get_unpacked_marks @@ -357,10 +358,12 @@ def pytest_collectstart(collector: pytest.Collector): continue @pytest.fixture( - scope="class", + scope="class" if isinstance(collector, pytest.Class) else "module", name="event_loop", ) - def scoped_event_loop(cls) -> Iterator[asyncio.AbstractEventLoop]: + def scoped_event_loop( + *args, # Function needs to accept "cls" when collected by pytest.Class + ) -> Iterator[asyncio.AbstractEventLoop]: loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @@ -368,6 +371,7 @@ def scoped_event_loop(cls) -> Iterator[asyncio.AbstractEventLoop]: # @pytest.fixture does not register the fixture anywhere, so pytest doesn't # know it exists. We work around this by attaching the fixture function to the # collected Python class, where it will be picked up by pytest.Class.collect() + # or pytest.Module.collect(), respectively collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop break diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index c870edb7..8a5e9338 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -50,3 +50,66 @@ def sample_fixture(): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio + async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_this_runs_in_same_loop(): + global loop + assert asyncio.get_running_loop() is loop + + class TestClassA: + @pytest.mark.asyncio + async def test_this_runs_in_same_loop(self): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +def test_asyncio_mark_provides_class_scoped_loop_auto_mode(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + async def test_this_runs_in_same_loop(): + global loop + assert asyncio.get_running_loop() is loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=3) From b753fd7d23297b51006ebf7f3413775b0a20b9ca Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 3 Oct 2023 14:14:54 +0200 Subject: [PATCH 04/13] [refactor] Run multiloop test inside Pytester to avoid the custom event loop implementation to pollute the test environment. Signed-off-by: Michael Seifert --- tests/multiloop/conftest.py | 15 ----- tests/multiloop/test_alternative_loops.py | 16 ------ tests/test_multiloop.py | 70 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 31 deletions(-) delete mode 100644 tests/multiloop/conftest.py delete mode 100644 tests/multiloop/test_alternative_loops.py create mode 100644 tests/test_multiloop.py diff --git a/tests/multiloop/conftest.py b/tests/multiloop/conftest.py deleted file mode 100644 index ebcb627a..00000000 --- a/tests/multiloop/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -import asyncio - -import pytest - - -class CustomSelectorLoop(asyncio.SelectorEventLoop): - """A subclass with no overrides, just to test for presence.""" - - -@pytest.fixture -def event_loop(): - """Create an instance of the default event loop for each test case.""" - loop = CustomSelectorLoop() - yield loop - loop.close() diff --git a/tests/multiloop/test_alternative_loops.py b/tests/multiloop/test_alternative_loops.py deleted file mode 100644 index 5f66c967..00000000 --- a/tests/multiloop/test_alternative_loops.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Unit tests for overriding the event loop.""" -import asyncio - -import pytest - - -@pytest.mark.asyncio -async def test_for_custom_loop(): - """This test should be executed using the custom loop.""" - await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" - - -@pytest.mark.asyncio -async def test_dependent_fixture(dependent_fixture): - await asyncio.sleep(0.1) diff --git a/tests/test_multiloop.py b/tests/test_multiloop.py new file mode 100644 index 00000000..6c47d68c --- /dev/null +++ b/tests/test_multiloop.py @@ -0,0 +1,70 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_event_loop_override(pytester: Pytester): + pytester.makeconftest( + dedent( + '''\ + import asyncio + + import pytest + + + @pytest.fixture + def dependent_fixture(event_loop): + """A fixture dependent on the event_loop fixture, doing some cleanup.""" + counter = 0 + + async def just_a_sleep(): + """Just sleep a little while.""" + nonlocal event_loop + await asyncio.sleep(0.1) + nonlocal counter + counter += 1 + + event_loop.run_until_complete(just_a_sleep()) + yield + event_loop.run_until_complete(just_a_sleep()) + + assert counter == 2 + + + class CustomSelectorLoop(asyncio.SelectorEventLoop): + """A subclass with no overrides, just to test for presence.""" + + + @pytest.fixture + def event_loop(): + """Create an instance of the default event loop for each test case.""" + loop = CustomSelectorLoop() + yield loop + loop.close() + ''' + ) + ) + pytester.makepyfile( + dedent( + '''\ + """Unit tests for overriding the event loop.""" + import asyncio + + import pytest + + + @pytest.mark.asyncio + async def test_for_custom_loop(): + """This test should be executed using the custom loop.""" + await asyncio.sleep(0.01) + assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" + + + @pytest.mark.asyncio + async def test_dependent_fixture(dependent_fixture): + await asyncio.sleep(0.1) + ''' + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From fc9de2629539aef3c04504cced18586914d519b3 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 3 Oct 2023 15:53:07 +0200 Subject: [PATCH 05/13] [refactor] Run parametrized loop test inside Pytester to prevent the event loop implementation from polluting the test environment. Signed-off-by: Michael Seifert --- .../async_fixtures/test_parametrized_loop.py | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py index 2fb8befa..2bdbe5e8 100644 --- a/tests/async_fixtures/test_parametrized_loop.py +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -1,31 +1,46 @@ -import asyncio +from textwrap import dedent -import pytest +from pytest import Pytester -TESTS_COUNT = 0 +def test_event_loop_parametrization(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio -def teardown_module(): - # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' - assert TESTS_COUNT == 4 + import pytest + import pytest_asyncio + TESTS_COUNT = 0 -@pytest.fixture(scope="module", params=[1, 2]) -def event_loop(request): - request.param - loop = asyncio.new_event_loop() - yield loop - loop.close() + def teardown_module(): + # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' + assert TESTS_COUNT == 4 -@pytest.fixture(params=["a", "b"]) -async def fix(request): - await asyncio.sleep(0) - return request.param + @pytest.fixture(scope="module", params=[1, 2]) + def event_loop(request): + request.param + loop = asyncio.new_event_loop() + yield loop + loop.close() -@pytest.mark.asyncio -async def test_parametrized_loop(fix): - await asyncio.sleep(0) - global TESTS_COUNT - TESTS_COUNT += 1 + + @pytest_asyncio.fixture(params=["a", "b"]) + async def fix(request): + await asyncio.sleep(0) + return request.param + + + @pytest.mark.asyncio + async def test_parametrized_loop(fix): + await asyncio.sleep(0) + global TESTS_COUNT + TESTS_COUNT += 1 + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=4) From e3442003c314d3b0882baeaf4340bc201637c1e7 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 25 Jul 2023 14:25:56 +0200 Subject: [PATCH 06/13] [refactor] The synchronization wrapper for coroutine and async generator tests no longer requires an explicit event loop argument. The wrapper retrieves the currently set loop via asyncio.get_event_loop, instead. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 794a3088..cdf160af 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -21,7 +21,6 @@ Set, TypeVar, Union, - cast, overload, ) @@ -509,19 +508,15 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: """ marker = pyfuncitem.get_closest_marker("asyncio") if marker is not None: - funcargs: Dict[str, object] = pyfuncitem.funcargs # type: ignore[name-defined] - loop = cast(asyncio.AbstractEventLoop, funcargs["event_loop"]) if _is_hypothesis_test(pyfuncitem.obj): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem, pyfuncitem.obj.hypothesis.inner_test, - _loop=loop, ) else: pyfuncitem.obj = wrap_in_sync( pyfuncitem, pyfuncitem.obj, - _loop=loop, ) yield @@ -533,7 +528,6 @@ def _is_hypothesis_test(function: Any) -> bool: def wrap_in_sync( pyfuncitem: pytest.Function, func: Callable[..., Awaitable[Any]], - _loop: asyncio.AbstractEventLoop, ): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -559,6 +553,7 @@ def inner(*args, **kwargs): ) ) return + _loop = asyncio.get_event_loop() task = asyncio.ensure_future(coro, loop=_loop) try: _loop.run_until_complete(task) From b9bed8bd3713e04a62147e1ee34f87c56803834a Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 10:39:05 +0200 Subject: [PATCH 07/13] [feat] Class-scoped and module-scoped event loops no longer override the function-scoped event_loop fixture. They rather provide a fixture with a different name, based on the nodeid of the pytest.Collector that has the "asyncio_event_loop" mark. When a test requests the event_loop fixture and a dynamically generated event loop with class or module scope, pytest-asyncio will raise an error. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 49 +++++++++++++++++++++++++++-- tests/markers/test_class_marker.py | 22 +++++++++++++ tests/markers/test_module_marker.py | 22 +++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index cdf160af..a7e70a78 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -34,6 +34,7 @@ Parser, PytestPluginManager, Session, + StashKey, ) _R = TypeVar("_R") @@ -55,6 +56,14 @@ SubRequest = Any +class PytestAsyncioError(Exception): + """Base class for exceptions raised by pytest-asyncio""" + + +class MultipleEventLoopsRequestedError(PytestAsyncioError): + """Raised when a test requests multiple asyncio event loops.""" + + class Mode(str, enum.Enum): AUTO = "auto" STRICT = "strict" @@ -345,6 +354,9 @@ def pytest_pycollect_makeitem( return None +_event_loop_fixture_id = StashKey[str] + + @pytest.hookimpl def pytest_collectstart(collector: pytest.Collector): if not isinstance(collector, (pytest.Class, pytest.Module)): @@ -356,9 +368,19 @@ def pytest_collectstart(collector: pytest.Collector): if not mark.name == "asyncio_event_loop": continue + # There seem to be issues when a fixture is shadowed by another fixture + # and both differ in their params. + # https://github.com/pytest-dev/pytest/issues/2043 + # https://github.com/pytest-dev/pytest/issues/11350 + # As such, we assign a unique name for each event_loop fixture. + # The fixture name is stored in the collector's Stash, so it can + # be injected when setting up the test + event_loop_fixture_id = f"{collector.nodeid}::" + collector.stash[_event_loop_fixture_id] = event_loop_fixture_id + @pytest.fixture( scope="class" if isinstance(collector, pytest.Class) else "module", - name="event_loop", + name=event_loop_fixture_id, ) def scoped_event_loop( *args, # Function needs to accept "cls" when collected by pytest.Class @@ -569,15 +591,38 @@ def inner(*args, **kwargs): return inner +_MULTIPLE_LOOPS_REQUESTED_ERROR = dedent( + """\ + Multiple asyncio event loops with different scopes have been requested + by %s. The test explicitly requests the event_loop fixture, while another + event loop is provided by %s. + Remove "event_loop" from the requested fixture in your test to run the test + in a larger-scoped event loop or remove the "asyncio_event_loop" mark to run + the test in a function-scoped event loop. + """ +) + + def pytest_runtest_setup(item: pytest.Item) -> None: marker = item.get_closest_marker("asyncio") if marker is None: return + event_loop_fixture_id = "event_loop" + for node, mark in item.iter_markers_with_node("asyncio_event_loop"): + scoped_event_loop_provider_node = node + event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) + if event_loop_fixture_id: + break fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests if "event_loop" in fixturenames: + if event_loop_fixture_id != "event_loop": + raise MultipleEventLoopsRequestedError( + _MULTIPLE_LOOPS_REQUESTED_ERROR + % (item.nodeid, scoped_event_loop_provider_node.nodeid), + ) fixturenames.remove("event_loop") - fixturenames.insert(0, "event_loop") + fixturenames.insert(0, event_loop_fixture_id) obj = getattr(item, "obj", None) if not getattr(obj, "hypothesis", False) and getattr( obj, "is_hypothesis_test", False diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index 19645747..9b39382a 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -104,3 +104,25 @@ async def test_this_runs_in_same_loop(self): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + @pytest.mark.asyncio + async def test_remember_loop(self, event_loop): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index 8a5e9338..53bfd7c1 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -113,3 +113,25 @@ async def test_this_runs_in_same_loop(self): ) result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=3) + + +def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + @pytest.mark.asyncio + async def test_remember_loop(event_loop): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") From c8a52a018f09b43a9a228043959a8eebb3efff68 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 12:33:43 +0200 Subject: [PATCH 08/13] [refactor] Tests for "loop_fixture_scope" create the custom event loop inside the event_loop fixture override rather than on the module-level. This prevents the custom loop from being created during test collection time. Signed-off-by: Michael Seifert --- tests/loop_fixture_scope/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/loop_fixture_scope/conftest.py b/tests/loop_fixture_scope/conftest.py index 223160c2..6b9a7649 100644 --- a/tests/loop_fixture_scope/conftest.py +++ b/tests/loop_fixture_scope/conftest.py @@ -7,11 +7,9 @@ class CustomSelectorLoop(asyncio.SelectorEventLoop): """A subclass with no overrides, just to test for presence.""" -loop = CustomSelectorLoop() - - @pytest.fixture(scope="module") def event_loop(): """Create an instance of the default event loop for each test case.""" + loop = CustomSelectorLoop() yield loop loop.close() From c24848dd51f00f8357e36327a6662b1972c1f3f4 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 13:13:05 +0200 Subject: [PATCH 09/13] [feat] The asyncio_event_loop mark specifying an optional event loop policy. Signed-off-by: Michael Seifert --- docs/source/reference/markers.rst | 21 ++++++++++++ pytest_asyncio/plugin.py | 44 +++++++++++++++++++++---- tests/markers/test_class_marker.py | 35 ++++++++++++++++++++ tests/markers/test_module_marker.py | 50 +++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index 9c4edc28..4d4b0213 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -107,5 +107,26 @@ Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` global loop assert asyncio.get_running_loop() is loop +The `asyncio_event_loop` mark supports an optional `policy` keyword argument to set the asyncio event loop policy. + +.. code-block:: python + + import asyncio + + import pytest + + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + + @pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) + class TestUsesCustomEventLoopPolicy: + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(self): + assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) + +If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``. + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a7e70a78..63867ca1 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -31,6 +31,7 @@ FixtureRequest, Function, Item, + Metafunc, Parser, PytestPluginManager, Session, @@ -367,6 +368,7 @@ def pytest_collectstart(collector: pytest.Collector): for mark in marks: if not mark.name == "asyncio_event_loop": continue + event_loop_policy = mark.kwargs.get("policy", asyncio.get_event_loop_policy()) # There seem to be issues when a fixture is shadowed by another fixture # and both differ in their params. @@ -381,13 +383,23 @@ def pytest_collectstart(collector: pytest.Collector): @pytest.fixture( scope="class" if isinstance(collector, pytest.Class) else "module", name=event_loop_fixture_id, + params=(event_loop_policy,), + ids=(type(event_loop_policy).__name__,), ) def scoped_event_loop( *args, # Function needs to accept "cls" when collected by pytest.Class + request, ) -> Iterator[asyncio.AbstractEventLoop]: - loop = asyncio.get_event_loop_policy().new_event_loop() + new_loop_policy = request.param + old_loop_policy = asyncio.get_event_loop_policy() + old_loop = asyncio.get_event_loop() + asyncio.set_event_loop_policy(new_loop_policy) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) yield loop loop.close() + asyncio.set_event_loop_policy(old_loop_policy) + asyncio.set_event_loop(old_loop) # @pytest.fixture does not register the fixture anywhere, so pytest doesn't # know it exists. We work around this by attaching the fixture function to the @@ -430,6 +442,30 @@ def _hypothesis_test_wraps_coroutine(function: Any) -> bool: return _is_coroutine(function.hypothesis.inner_test) +@pytest.hookimpl(tryfirst=True) +def pytest_generate_tests(metafunc: Metafunc) -> None: + for event_loop_provider_node, _ in metafunc.definition.iter_markers_with_node( + "asyncio_event_loop" + ): + event_loop_fixture_id = event_loop_provider_node.stash.get( + _event_loop_fixture_id, None + ) + if event_loop_fixture_id: + fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") + if "event_loop" in metafunc.fixturenames: + raise MultipleEventLoopsRequestedError( + _MULTIPLE_LOOPS_REQUESTED_ERROR + % (metafunc.definition.nodeid, event_loop_provider_node.nodeid), + ) + # Add the scoped event loop fixture to Metafunc's list of fixture names and + # fixturedefs and leave the actual parametrization to pytest + metafunc.fixturenames.insert(0, event_loop_fixture_id) + metafunc._arg2fixturedefs[ + event_loop_fixture_id + ] = fixturemanager._arg2fixturedefs[event_loop_fixture_id] + break + + @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef, request: SubRequest @@ -609,18 +645,12 @@ def pytest_runtest_setup(item: pytest.Item) -> None: return event_loop_fixture_id = "event_loop" for node, mark in item.iter_markers_with_node("asyncio_event_loop"): - scoped_event_loop_provider_node = node event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) if event_loop_fixture_id: break fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests if "event_loop" in fixturenames: - if event_loop_fixture_id != "event_loop": - raise MultipleEventLoopsRequestedError( - _MULTIPLE_LOOPS_REQUESTED_ERROR - % (item.nodeid, scoped_event_loop_provider_node.nodeid), - ) fixturenames.remove("event_loop") fixturenames.insert(0, event_loop_fixture_id) obj = getattr(item, "obj", None) diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index 9b39382a..dd8e2dc1 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -126,3 +126,38 @@ async def test_remember_loop(self, event_loop): result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(errors=1) result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + + +def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + @pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) + class TestUsesCustomEventLoopPolicy: + + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(self): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + + @pytest.mark.asyncio + async def test_does_not_use_custom_event_loop_policy(): + assert not isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index 53bfd7c1..781e4513 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -135,3 +135,53 @@ async def test_remember_loop(event_loop): result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(errors=1) result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + + +def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + custom_policy=dedent( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) + + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_does_not_use_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.mark.asyncio + async def test_does_not_use_custom_event_loop_policy(): + assert not isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From 53ff0253d106016304d466e04a30bf8f67c2d75f Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 14:16:29 +0200 Subject: [PATCH 10/13] [feat] The "policy" keyword argument to asyncio_event_loop allows passing an iterable of policies. This causes tests under the _asyncio_event_loop_ mark to be parametrized with the different loop policies. Signed-off-by: Michael Seifert --- docs/source/reference/markers.rst | 24 ++++++++++++++++++++++++ pytest_asyncio/plugin.py | 9 +++++++-- tests/markers/test_class_marker.py | 27 +++++++++++++++++++++++++++ tests/markers/test_module_marker.py | 27 +++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index 4d4b0213..7b304045 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -126,6 +126,30 @@ The `asyncio_event_loop` mark supports an optional `policy` keyword argument to async def test_uses_custom_event_loop_policy(self): assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) + +The ``policy`` keyword argument may also take an iterable of event loop policies. This causes tests under by the `asyncio_event_loop` mark to be parametrized with different policies: + +.. code-block:: python + + import asyncio + + import pytest + + import pytest_asyncio + + + @pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + uvloop.EventLoopPolicy(), + ] + ) + class TestWithDifferentLoopPolicies: + @pytest.mark.asyncio + async def test_parametrized_loop(self): + pass + + If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``. .. |pytestmark| replace:: ``pytestmark`` diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 63867ca1..b2bde15f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -369,6 +369,11 @@ def pytest_collectstart(collector: pytest.Collector): if not mark.name == "asyncio_event_loop": continue event_loop_policy = mark.kwargs.get("policy", asyncio.get_event_loop_policy()) + policy_params = ( + event_loop_policy + if isinstance(event_loop_policy, Iterable) + else (event_loop_policy,) + ) # There seem to be issues when a fixture is shadowed by another fixture # and both differ in their params. @@ -383,8 +388,8 @@ def pytest_collectstart(collector: pytest.Collector): @pytest.fixture( scope="class" if isinstance(collector, pytest.Class) else "module", name=event_loop_fixture_id, - params=(event_loop_policy,), - ids=(type(event_loop_policy).__name__,), + params=policy_params, + ids=tuple(type(policy).__name__ for policy in policy_params), ) def scoped_event_loop( *args, # Function needs to accept "cls" when collected by pytest.Class diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index dd8e2dc1..f9a2f680 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -161,3 +161,30 @@ async def test_does_not_use_custom_event_loop_policy(): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + @pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ] + ) + class TestWithDifferentLoopPolicies: + @pytest.mark.asyncio + async def test_parametrized_loop(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index 781e4513..b0926750 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -185,3 +185,30 @@ async def test_does_not_use_custom_event_loop_policy(): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ] + ) + + @pytest.mark.asyncio + async def test_parametrized_loop(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From a999328a6bf37b3805d228496252f21367378709 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 15:37:53 +0200 Subject: [PATCH 11/13] [feat] Fixtures and tests sharing the same asyncio_event_loop mark are executed in the same event loop. Signed-off-by: Michael Seifert --- docs/source/reference/markers.rst | 24 +++++++++++ pytest_asyncio/plugin.py | 62 +++++++++++++++++++---------- tests/markers/test_class_marker.py | 29 ++++++++++++++ tests/markers/test_module_marker.py | 31 +++++++++++++++ 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index 7b304045..68d5efd3 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -152,5 +152,29 @@ The ``policy`` keyword argument may also take an iterable of event loop policies If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``. +Fixtures and tests sharing the same `asyncio_event_loop` mark are executed in the same event loop: + +.. code-block:: python + + import asyncio + + import pytest + + import pytest_asyncio + + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + async def my_fixture(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_is_same_loop_as_fixture(self, my_fixture): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index b2bde15f..7d41779f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -27,6 +27,7 @@ import pytest from _pytest.mark.structures import get_unpacked_marks from pytest import ( + Collector, Config, FixtureRequest, Function, @@ -202,11 +203,17 @@ def pytest_report_header(config: Config) -> List[str]: def _preprocess_async_fixtures( - config: Config, + collector: Collector, processed_fixturedefs: Set[FixtureDef], ) -> None: + config = collector.config asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") + event_loop_fixture_id = "event_loop" + for node, mark in collector.iter_markers_with_node("asyncio_event_loop"): + event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) + if event_loop_fixture_id: + break for fixtures in fixturemanager._arg2fixturedefs.values(): for fixturedef in fixtures: func = fixturedef.func @@ -219,37 +226,42 @@ def _preprocess_async_fixtures( # This applies to pytest_trio fixtures, for example continue _make_asyncio_fixture_function(func) - _inject_fixture_argnames(fixturedef) - _synchronize_async_fixture(fixturedef) + _inject_fixture_argnames(fixturedef, event_loop_fixture_id) + _synchronize_async_fixture(fixturedef, event_loop_fixture_id) assert _is_asyncio_fixture_function(fixturedef.func) processed_fixturedefs.add(fixturedef) -def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: +def _inject_fixture_argnames( + fixturedef: FixtureDef, event_loop_fixture_id: str +) -> None: """ Ensures that `request` and `event_loop` are arguments of the specified fixture. """ to_add = [] - for name in ("request", "event_loop"): + for name in ("request", event_loop_fixture_id): if name not in fixturedef.argnames: to_add.append(name) if to_add: fixturedef.argnames += tuple(to_add) -def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: +def _synchronize_async_fixture( + fixturedef: FixtureDef, event_loop_fixture_id: str +) -> None: """ Wraps the fixture function of an async fixture in a synchronous function. """ if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef) + _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef) + _wrap_async_fixture(fixturedef, event_loop_fixture_id) def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], + event_loop_fixture_id: str, event_loop: asyncio.AbstractEventLoop, request: SubRequest, ) -> Dict[str, Any]: @@ -257,8 +269,8 @@ def _add_kwargs( ret = kwargs.copy() if "request" in sig.parameters: ret["request"] = request - if "event_loop" in sig.parameters: - ret["event_loop"] = event_loop + if event_loop_fixture_id in sig.parameters: + ret[event_loop_fixture_id] = event_loop return ret @@ -281,17 +293,18 @@ def _perhaps_rebind_fixture_func( return func -def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: +def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: fixture = fixturedef.func @functools.wraps(fixture) - def _asyncgen_fixture_wrapper( - event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any - ): + def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + event_loop = kwargs.pop(event_loop_fixture_id) + gen_obj = func( + **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + ) async def setup(): res = await gen_obj.__anext__() @@ -319,19 +332,20 @@ async def async_finalizer() -> None: fixturedef.func = _asyncgen_fixture_wrapper -def _wrap_async_fixture(fixturedef: FixtureDef) -> None: +def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: fixture = fixturedef.func @functools.wraps(fixture) - def _async_fixture_wrapper( - event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any - ): + def _async_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) + event_loop = kwargs.pop(event_loop_fixture_id) async def setup(): - res = await func(**_add_kwargs(func, kwargs, event_loop, request)) + res = await func( + **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + ) return res return event_loop.run_until_complete(setup()) @@ -351,7 +365,7 @@ def pytest_pycollect_makeitem( """A pytest hook to collect asyncio coroutines.""" if not collector.funcnamefilter(name): return None - _preprocess_async_fixtures(collector.config, _HOLDER) + _preprocess_async_fixtures(collector, _HOLDER) return None @@ -456,6 +470,12 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: _event_loop_fixture_id, None ) if event_loop_fixture_id: + # 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 + # asyncio_event_loop mark. + if event_loop_fixture_id in metafunc.fixturenames: + continue fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") if "event_loop" in metafunc.fixturenames: raise MultipleEventLoopsRequestedError( diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index f9a2f680..68425575 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -188,3 +188,32 @@ async def test_parametrized_loop(self): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + async def my_fixture(self): + TestClassScopedLoop.loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_is_same_loop_as_fixture(self, my_fixture): + assert asyncio.get_running_loop() is TestClassScopedLoop.loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index b0926750..f6cd8762 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -212,3 +212,34 @@ async def test_parametrized_loop(): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + pytestmark = pytest.mark.asyncio_event_loop + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + async def my_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_is_same_loop_as_fixture(my_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) From b525cf3f4659e8583393ca7a67d212a15e4799a9 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 16:21:57 +0200 Subject: [PATCH 12/13] [build] Update flake8 version in the pre-commit hooks to v6.1.0. Signed-off-by: Michael Seifert --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc81f2f5..4e5d2f8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: - id: mypy exclude: ^(docs|tests)/.* - repo: https://github.com/pycqa/flake8 - rev: 5.0.4 + rev: 6.1.0 hooks: - id: flake8 language_version: python3 From 14d1c613de56c1bf4d886ef58605da81230195e0 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 9 Oct 2023 16:46:58 +0200 Subject: [PATCH 13/13] [docs] Added changelog entry for the asyncio_event_loop mark. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index 77204145..13c5080b 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -6,6 +6,8 @@ Changelog ================== - Remove support for Python 3.7 - Declare support for Python 3.12 +- Class-scoped and module-scoped event loops can be requested + via the _asyncio_event_loop_ mark. `#620 `_ 0.21.1 (2023-07-12) ===================