From aae1fd7c1851d677cda0fc6c188eef2238a4c7e1 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Mon, 8 Jul 2024 15:41:40 +0200 Subject: [PATCH 1/3] [feat] Raises a MultipleEventLoopsRequested error when a function-scoped loop is pulled in by an async autouse fixture in addition to an event loop with larger scope. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 1 + pytest_asyncio/plugin.py | 16 ++++----- tests/async_fixtures/test_autouse_fixtures.py | 36 +++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 tests/async_fixtures/test_autouse_fixtures.py diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index abf27a0a..f0d8e71e 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -9,6 +9,7 @@ Changelog - Deprecates the optional `scope` keyword argument to `pytest.mark.asyncio` for API consistency with ``pytest_asyncio.fixture``. Users are encouraged to use the `loop_scope` keyword argument, which does exactly the same. - Raises an error when passing `scope` or `loop_scope` as a positional argument to ``@pytest.mark.asyncio``. `#812 `_ - Fixes a bug that caused module-scoped async fixtures to fail when reused in other modules `#862 `_ `#668 `_ +- Improves detection of multiple event loops being requested by the same test in strict mode `#868 `_ 0.23.8 (2024-07-17) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 178fcaa6..b9e1260d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -730,14 +730,6 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: event_loop_fixture_id = event_loop_node.stash.get(_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 - # event loop fixture mark. - if event_loop_fixture_id in metafunc.fixturenames: - return - fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") - assert fixturemanager is not None if "event_loop" in metafunc.fixturenames: raise MultipleEventLoopsRequestedError( _MULTIPLE_LOOPS_REQUESTED_ERROR.format( @@ -746,6 +738,14 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: scoped_loop_node=event_loop_node.nodeid, ), ) + # 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 event_loop_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 diff --git a/tests/async_fixtures/test_autouse_fixtures.py b/tests/async_fixtures/test_autouse_fixtures.py new file mode 100644 index 00000000..ddbf59f8 --- /dev/null +++ b/tests/async_fixtures/test_autouse_fixtures.py @@ -0,0 +1,36 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_autouse_fixture_in_different_scope_triggers_multiple_event_loop_error( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(autouse=True) + async def autouse_fixture(): + pass + + @pytest_asyncio.fixture(scope="session") + async def any_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="session") + async def test_runs_in_session_scoped_loop(any_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") From 8095cdef0cbb90277eaede28d4d28f6705d1a78e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 9 Jul 2024 06:47:32 +0200 Subject: [PATCH 2/3] [refactor] Extracted function to iterate over the collector hierarchy of a test item. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index b9e1260d..d171ca64 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -8,6 +8,7 @@ import socket import warnings from asyncio import AbstractEventLoopPolicy +from itertools import dropwhile from textwrap import dedent from typing import ( Any, @@ -1009,11 +1010,13 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: "package": Package, "session": Session, } + collectors = _iter_collectors(item) scope_root_type = node_type_by_scope[scope] - for node in reversed(item.listchain()): - if isinstance(node, scope_root_type): - assert isinstance(node, pytest.Collector) - return node + collector_with_specified_scope = next( + dropwhile(lambda c: not isinstance(c, scope_root_type), collectors), None + ) + if collector_with_specified_scope: + return collector_with_specified_scope error_message = ( f"{item.name} is marked to be run in an event loop with scope {scope}, " f"but is not part of any {scope}." @@ -1021,6 +1024,12 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: raise pytest.UsageError(error_message) +def _iter_collectors(item: Union[Collector, Item]) -> Iterable[Collector]: + for node in reversed(item.listchain()): + if isinstance(node, pytest.Collector): + yield node + + @pytest.fixture def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" From a70577c0f8e32ba4070bd841631c64a5c43b6901 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Tue, 9 Jul 2024 08:21:34 +0200 Subject: [PATCH 3/3] [feat] Detect multiple higher-scoped event loops requested in the same test. Previously, the detection was limited to a function-scoped loop and a higher-scoped loop. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 14 +++++++++++++- tests/async_fixtures/test_autouse_fixtures.py | 7 +++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d171ca64..06c88e94 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -731,7 +731,19 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None) if event_loop_fixture_id: - if "event_loop" in metafunc.fixturenames: + collectors = _iter_collectors(metafunc.definition) + collector_event_loop_fixture_ids = ( + c.stash.get(_event_loop_fixture_id, None) # type: ignore[arg-type] + for c in collectors + ) + possible_event_loop_fixture_ids = {"event_loop"} | set( + collector_event_loop_fixture_ids + ) + used_fixture_ids = {event_loop_fixture_id, *metafunc.fixturenames} + used_event_loop_fixture_ids = possible_event_loop_fixture_ids.intersection( + used_fixture_ids + ) + if len(used_event_loop_fixture_ids) > 1: raise MultipleEventLoopsRequestedError( _MULTIPLE_LOOPS_REQUESTED_ERROR.format( test_name=metafunc.definition.nodeid, diff --git a/tests/async_fixtures/test_autouse_fixtures.py b/tests/async_fixtures/test_autouse_fixtures.py index ddbf59f8..1c99be67 100644 --- a/tests/async_fixtures/test_autouse_fixtures.py +++ b/tests/async_fixtures/test_autouse_fixtures.py @@ -1,21 +1,24 @@ from textwrap import dedent +import pytest from pytest import Pytester +@pytest.mark.parametrize("autouse_fixture_scope", ("function", "module")) def test_autouse_fixture_in_different_scope_triggers_multiple_event_loop_error( pytester: Pytester, + autouse_fixture_scope: str, ): pytester.makepyfile( dedent( - """\ + f"""\ import asyncio import pytest import pytest_asyncio loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(autouse=True) + @pytest_asyncio.fixture(autouse=True, scope="{autouse_fixture_scope}") async def autouse_fixture(): pass