From 6629bdd679673b63b8b12c298f877939098869ff Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 13:29:52 +0200 Subject: [PATCH 1/8] refactor: Async fixtures no longer depend on a node's StashKey to retrieve the name of the scoped event loop fixture. --- pytest_asyncio/plugin.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 44fece73..6d18b7bf 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -28,6 +28,7 @@ Literal, TypeVar, Union, + cast, overload, ) @@ -396,21 +397,13 @@ async def setup(): def _get_event_loop_fixture_id_for_async_fixture( request: FixtureRequest, func: Any ) -> str: - default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope") + default_loop_scope = cast( + _ScopeName, request.config.getini("asyncio_default_fixture_loop_scope") + ) loop_scope = ( getattr(func, "_loop_scope", None) or default_loop_scope or request.scope ) - if loop_scope == "function": - event_loop_fixture_id = "_function_event_loop" - else: - event_loop_node = _retrieve_scope_root(request._pyfuncitem, loop_scope) - event_loop_fixture_id = event_loop_node.stash.get( - # Type ignored because of non-optimal mypy inference. - _event_loop_fixture_id, # type: ignore[arg-type] - "", - ) - assert event_loop_fixture_id - return event_loop_fixture_id + return f"_{loop_scope}_event_loop" def _create_task_in_context( From 6a75a10c0156eb656e344ca40949d8d7f0fadc65 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 13:34:30 +0200 Subject: [PATCH 2/8] refactor: pytest_runtest_setup no longer depends on a node's StashKey to retrieve the name of the scoped event loop fixture. --- pytest_asyncio/plugin.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 6d18b7bf..f3b32e76 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -811,12 +811,8 @@ def pytest_runtest_setup(item: pytest.Item) -> None: if marker is None: return default_loop_scope = _get_default_test_loop_scope(item.config) - scope = _get_marked_loop_scope(marker, default_loop_scope) - if scope != "function": - parent_node = _retrieve_scope_root(item, scope) - event_loop_fixture_id = parent_node.stash[_event_loop_fixture_id] - else: - event_loop_fixture_id = "_function_event_loop" + loop_scope = _get_marked_loop_scope(marker, default_loop_scope) + event_loop_fixture_id = f"_{loop_scope}_event_loop" fixturenames = item.fixturenames # type: ignore[attr-defined] if event_loop_fixture_id not in fixturenames: fixturenames.append(event_loop_fixture_id) From 01e2bb6076e2ee331d06e77d863583e8d721d3df Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:01:38 +0200 Subject: [PATCH 3/8] feat: Tests using package or class loop scopes no longer raise an error when there's no surrounding package or class. --- changelog.d/+bd8f0ee6.changed.rst | 1 + docs/reference/markers/index.rst | 4 --- pytest_asyncio/plugin.py | 41 +++++++++++++---------------- tests/markers/test_class_scope.py | 23 ---------------- tests/markers/test_package_scope.py | 20 -------------- 5 files changed, 20 insertions(+), 69 deletions(-) create mode 100644 changelog.d/+bd8f0ee6.changed.rst diff --git a/changelog.d/+bd8f0ee6.changed.rst b/changelog.d/+bd8f0ee6.changed.rst new file mode 100644 index 00000000..3a4976e3 --- /dev/null +++ b/changelog.d/+bd8f0ee6.changed.rst @@ -0,0 +1 @@ +The *loop_scope* argument to ``pytest.mark.asyncio`` no longer forces that a pytest Collector exists at the level of the specified scope. For example, a test function marked with ``pytest.mark.asyncio(loop_scope="class")`` no longer requires a class surrounding the test. This is consistent with the behavior of the *scope* argument to ``pytest_asyncio.fixture``. diff --git a/docs/reference/markers/index.rst b/docs/reference/markers/index.rst index e7d700c9..e9c12de1 100644 --- a/docs/reference/markers/index.rst +++ b/docs/reference/markers/index.rst @@ -27,15 +27,11 @@ The following code example provides a shared event loop for all tests in `TestCl .. include:: class_scoped_loop_strict_mode_example.py :code: python -If you request class scope for a test that is not part of a class, it will result in a *UsageError*. Similar to class-scoped event loops, a module-scoped loop is provided when setting mark's scope to *module:* .. include:: module_scoped_loop_strict_mode_example.py :code: python -Package-scoped loops only work with `regular Python packages. `__ -That means they require an *__init__.py* to be present. -Package-scoped loops do not work in `namespace packages. `__ Subpackages do not share the loop with their parent package. Tests marked with *session* scope share the same event loop, even if the tests exist in different packages. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index f3b32e76..311c5996 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -687,29 +687,26 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: if not marker: return default_loop_scope = _get_default_test_loop_scope(metafunc.config) - scope = _get_marked_loop_scope(marker, default_loop_scope) - if scope == "function": + loop_scope = _get_marked_loop_scope(marker, default_loop_scope) + if loop_scope == "function": return - event_loop_node = _retrieve_scope_root(metafunc.definition, scope) - 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 - # 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(event_loop_fixture_id) - metafunc._arg2fixturedefs[event_loop_fixture_id] = ( - fixturemanager._arg2fixturedefs[event_loop_fixture_id] - ) + event_loop_fixture_id = f"_{loop_scope}_event_loop" + # 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 + # order + metafunc.fixturenames.append(event_loop_fixture_id) + metafunc._arg2fixturedefs[event_loop_fixture_id] = fixturemanager._arg2fixturedefs[ + event_loop_fixture_id + ] def _get_event_loop_no_warn( diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 4bddb4b8..e8732e86 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -82,29 +82,6 @@ async def test_this_runs_in_same_loop(self): result.assert_outcomes(passed=2) -def test_asyncio_mark_raises_when_class_scoped_is_request_without_class( - pytester: pytest.Pytester, -): - pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.mark.asyncio(loop_scope="class") - async def test_has_no_surrounding_class(): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines( - "*is marked to be run in an event loop with scope*", - ) - - def test_asyncio_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index 94adba22..3e41459b 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -339,23 +339,3 @@ async def test_does_not_fail(sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) - - -def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( - pytester: Pytester, -): - pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") - pytester.makepyfile( - __init__="", - test_module=dedent( - """\ - import pytest - - @pytest.mark.asyncio(loop_scope="package") - async def test_anything(): - pass - """ - ), - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(warnings=0, passed=1) From f23a7741614d6d38fe4cbb4306f532a40750990d Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:05:59 +0200 Subject: [PATCH 4/8] refactor: Event loop fixture IDs are no longer stored in the node stash. --- pytest_asyncio/plugin.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 311c5996..922ee291 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -19,7 +19,6 @@ Generator, Iterable, Iterator, - Mapping, Sequence, ) from typing import ( @@ -52,7 +51,6 @@ PytestDeprecationWarning, PytestPluginManager, Session, - StashKey, ) if sys.version_info >= (3, 10): @@ -641,31 +639,6 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( hook_result.force_result(updated_node_collection) -_event_loop_fixture_id = StashKey[str]() -_fixture_scope_by_collector_type: Mapping[type[pytest.Collector], _ScopeName] = { - Class: "class", - # Package is a subclass of module and the dict is used in isinstance checks - # Therefore, the order matters and Package needs to appear before Module - Package: "package", - Module: "module", - Session: "session", -} - - -@pytest.hookimpl -def pytest_collectstart(collector: pytest.Collector) -> None: - try: - collector_scope = next( - scope - for cls, scope in _fixture_scope_by_collector_type.items() - if isinstance(collector, cls) - ) - except StopIteration: - return - event_loop_fixture_id = f"_{collector_scope}_event_loop" - collector.stash[_event_loop_fixture_id] = event_loop_fixture_id - - @contextlib.contextmanager def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: old_loop_policy = asyncio.get_event_loop_policy() From e1517a64fa0834a6578075270e7429d68a53a4a5 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:07:20 +0200 Subject: [PATCH 5/8] docs: Mention missing loop scopes in asyncio marker reference --- docs/reference/markers/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/markers/index.rst b/docs/reference/markers/index.rst index e9c12de1..7715077b 100644 --- a/docs/reference/markers/index.rst +++ b/docs/reference/markers/index.rst @@ -21,7 +21,7 @@ The ``pytest.mark.asyncio`` marker can be omitted entirely in |auto mode|_ where By default, each test runs in it's own asyncio event loop. Multiple tests can share the same event loop by providing a *loop_scope* keyword argument to the *asyncio* mark. -The supported scopes are *class,* and *module,* and *package*. +The supported scopes are *function,* *class,* and *module,* *package,* and *session*. The following code example provides a shared event loop for all tests in `TestClassScopedLoop`: .. include:: class_scoped_loop_strict_mode_example.py From f23e8be6d4845b40a59dafcf4af56cbe2c29a8da Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:11:12 +0200 Subject: [PATCH 6/8] refactor: Removed obsolete function _retrieve_scope_root --- pytest_asyncio/plugin.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 922ee291..076a91cc 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -35,7 +35,6 @@ import pytest from _pytest.scope import Scope from pytest import ( - Class, Collector, Config, FixtureDef, @@ -44,13 +43,10 @@ Item, Mark, Metafunc, - Module, - Package, Parser, PytestCollectionWarning, PytestDeprecationWarning, PytestPluginManager, - Session, ) if sys.version_info >= (3, 10): @@ -832,25 +828,6 @@ def _get_default_test_loop_scope(config: Config) -> _ScopeName: return config.getini("asyncio_default_test_loop_scope") -def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector: - node_type_by_scope = { - "class": Class, - "module": Module, - "package": Package, - "session": Session, - } - 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 - 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}." - ) - raise pytest.UsageError(error_message) - - def _create_scoped_event_loop_fixture(scope: _ScopeName) -> Callable: @pytest.fixture( scope=scope, From 562f958b6f5733f1c8047bb0bd7dab4a01e56b9b Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:18:28 +0200 Subject: [PATCH 7/8] refactor: Remove exception from injecting the scoped event loop into tests with function loop scope. This was needed for compatibility with the *event_loop* fixture which has since been removed. --- pytest_asyncio/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 076a91cc..a38cd806 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -657,8 +657,6 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: return default_loop_scope = _get_default_test_loop_scope(metafunc.config) loop_scope = _get_marked_loop_scope(marker, default_loop_scope) - if loop_scope == "function": - return event_loop_fixture_id = f"_{loop_scope}_event_loop" # This specific fixture name may already be in metafunc.argnames, if this # test indirectly depends on the fixture. For example, this is the case From 88e8454facb98ed408f56c3684138a1218812dd8 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 8 May 2025 14:23:17 +0200 Subject: [PATCH 8/8] refactor: Removed unnecessary code in async fixture preprocessing. --- pytest_asyncio/plugin.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a38cd806..b4f4f637 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -255,11 +255,6 @@ def _preprocess_async_fixtures( or default_loop_scope or fixturedef.scope ) - if ( - loop_scope == "function" - and "_function_event_loop" not in fixturedef.argnames - ): - fixturedef.argnames += ("_function_event_loop",) _make_asyncio_fixture_function(func, loop_scope) if "request" not in fixturedef.argnames: fixturedef.argnames += ("request",)