Skip to content

Simplify scoped loop fixture discovery #1112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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