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..7715077b 100644 --- a/docs/reference/markers/index.rst +++ b/docs/reference/markers/index.rst @@ -21,21 +21,17 @@ 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 :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 44fece73..b4f4f637 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -19,7 +19,6 @@ Generator, Iterable, Iterator, - Mapping, Sequence, ) from typing import ( @@ -28,6 +27,7 @@ Literal, TypeVar, Union, + cast, overload, ) @@ -35,7 +35,6 @@ import pytest from _pytest.scope import Scope from pytest import ( - Class, Collector, Config, FixtureDef, @@ -44,14 +43,10 @@ Item, Mark, Metafunc, - Module, - Package, Parser, PytestCollectionWarning, PytestDeprecationWarning, PytestPluginManager, - Session, - StashKey, ) if sys.version_info >= (3, 10): @@ -260,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",) @@ -396,21 +386,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( @@ -648,31 +630,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() @@ -694,29 +651,24 @@ 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) + 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 - 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] - ) + 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( @@ -818,12 +770,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) @@ -873,25 +821,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, 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)