Skip to content
1 change: 1 addition & 0 deletions changelog.d/+bd8f0ee6.changed.rst
Original file line number Diff line number Diff line change
@@ -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``.
6 changes: 1 addition & 5 deletions docs/reference/markers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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. <https://docs.python.org/3/glossary.html#term-regular-package>`__
That means they require an *__init__.py* to be present.
Package-scoped loops do not work in `namespace packages. <https://docs.python.org/3/glossary.html#term-namespace-package>`__
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.
Expand Down
119 changes: 24 additions & 95 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from typing import (
Expand All @@ -28,14 +27,14 @@
Literal,
TypeVar,
Union,
cast,
overload,
)

import pluggy
import pytest
from _pytest.scope import Scope
from pytest import (
Class,
Collector,
Config,
FixtureDef,
Expand All @@ -44,14 +43,10 @@
Item,
Mark,
Metafunc,
Module,
Package,
Parser,
PytestCollectionWarning,
PytestDeprecationWarning,
PytestPluginManager,
Session,
StashKey,
)

if sys.version_info >= (3, 10):
Expand Down Expand Up @@ -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",)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 0 additions & 23 deletions tests/markers/test_class_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 0 additions & 20 deletions tests/markers/test_package_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading