Skip to content

Commit

Permalink
feat: Configuration option for setting default loop_scope for tests
Browse files Browse the repository at this point in the history
New configuration option, asyncio_default_test_loop_scope, provides
default value for loop_scope argument of asyncio marker. This can be
used to use the same event loop in auto mode without need to use
modifyitems hook.

Test functions can still override loop_scope by using asyncio marker.
  • Loading branch information
Novakov committed Jan 6, 2025
1 parent 623ab74 commit 5272cc4
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 6 deletions.
24 changes: 24 additions & 0 deletions docs/how-to-guides/change_default_test_loop.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
=======================================================
How to change the default event loop scope of all tests
=======================================================
The :ref:`configuration/asyncio_default_test_loop_scope` configuration option sets the default event loop scope for asynchronous tests. The following code snippets configure all tests to run in a session-scoped loop by default:

.. code-block:: ini
:caption: pytest.ini
[pytest]
asyncio_default_test_loop_scope = session
.. code-block:: toml
:caption: pyproject.toml
[tool.pytest.ini_options]
asyncio_default_test_loop_scope = "session"
.. code-block:: ini
:caption: setup.cfg
[tool:pytest]
asyncio_default_test_loop_scope = session
Please refer to :ref:`configuration/asyncio_default_test_loop_scope` for other valid scopes.
1 change: 1 addition & 0 deletions docs/how-to-guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ How-To Guides
migrate_from_0_23
change_fixture_loop
change_default_fixture_loop
change_default_test_loop
run_class_tests_in_same_loop
run_module_tests_in_same_loop
run_package_tests_in_same_loop
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ asyncio_default_fixture_loop_scope
==================================
Determines the default event loop scope of asynchronous fixtures. When this configuration option is unset, it defaults to the fixture scope. In future versions of pytest-asyncio, the value will default to ``function`` when unset. Possible values are: ``function``, ``class``, ``module``, ``package``, ``session``

.. _configuration/asyncio_default_test_loop_scope:

asyncio_default_test_loop_scope
===============================
Determines the default event loop scope of asynchronous tests. When this configuration option is unset, it default to function scope. Possible values are: ``function``, ``class``, ``module``, ``package``, ``session``

asyncio_mode
============
The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file
Expand Down
34 changes: 28 additions & 6 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None
help="default scope of the asyncio event loop used to execute async fixtures",
default=None,
)
parser.addini(
"asyncio_default_test_loop_scope",
type="string",
help="default scope of the asyncio event loop used to execute tests",
default="function",
)


@overload
Expand Down Expand Up @@ -217,9 +223,15 @@ def pytest_configure(config: Config) -> None:
def pytest_report_header(config: Config) -> list[str]:
"""Add asyncio config to pytest header."""
mode = _get_asyncio_mode(config)
default_loop_scope = config.getini("asyncio_default_fixture_loop_scope")
default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope")
default_test_loop_scope = _get_default_test_loop_scope(config)
header = [
f"mode={mode}",
f"asyncio_default_fixture_loop_scope={default_fixture_loop_scope}",
f"asyncio_default_test_loop_scope={default_test_loop_scope}",
]
return [
f"asyncio: mode={mode}, asyncio_default_fixture_loop_scope={default_loop_scope}"
"asyncio: " + ", ".join(header),
]


Expand Down Expand Up @@ -806,7 +818,8 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
marker = metafunc.definition.get_closest_marker("asyncio")
if not marker:
return
scope = _get_marked_loop_scope(marker)
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
scope = _get_marked_loop_scope(marker, default_loop_scope)
if scope == "function":
return
event_loop_node = _retrieve_scope_root(metafunc.definition, scope)
Expand Down Expand Up @@ -1077,7 +1090,8 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
marker = item.get_closest_marker("asyncio")
if marker is None:
return
scope = _get_marked_loop_scope(marker)
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]
Expand Down Expand Up @@ -1107,7 +1121,9 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
"""


def _get_marked_loop_scope(asyncio_marker: Mark) -> _ScopeName:
def _get_marked_loop_scope(
asyncio_marker: Mark, default_loop_scope: _ScopeName
) -> _ScopeName:
assert asyncio_marker.name == "asyncio"
if asyncio_marker.args or (
asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"}
Expand All @@ -1118,12 +1134,18 @@ def _get_marked_loop_scope(asyncio_marker: Mark) -> _ScopeName:
raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR)
warnings.warn(PytestDeprecationWarning(_MARKER_SCOPE_KWARG_DEPRECATION_WARNING))
scope = asyncio_marker.kwargs.get("loop_scope") or asyncio_marker.kwargs.get(
"scope", "function"
"scope"
)
if scope is None:
scope = default_loop_scope
assert scope in {"function", "class", "module", "package", "session"}
return scope


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,
Expand Down
77 changes: 77 additions & 0 deletions tests/test_asyncio_mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,80 @@ async def test_a():
result.stdout.fnmatch_lines(
["*Tests based on asynchronous generators are not supported*"]
)


def test_asyncio_marker_fallbacks_to_configured_default_loop_scope_if_not_set(
pytester: Pytester,
):
pytester.makeini(
dedent(
"""\
[pytest]
asyncio_default_fixture_loop_scope = function
asyncio_default_test_loop_scope = session
"""
)
)

pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest_asyncio
import pytest
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def session_loop_fixture():
global loop
loop = asyncio.get_running_loop()
async def test_a(session_loop_fixture):
global loop
assert asyncio.get_running_loop() is loop
"""
)
)

result = pytester.runpytest("--asyncio-mode=auto")
result.assert_outcomes(passed=1)


def test_asyncio_marker_uses_marker_loop_scope_even_if_config_is_set(
pytester: Pytester,
):
pytester.makeini(
dedent(
"""\
[pytest]
asyncio_default_fixture_loop_scope = function
asyncio_default_test_loop_scope = module
"""
)
)

pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest_asyncio
import pytest
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def session_loop_fixture():
global loop
loop = asyncio.get_running_loop()
@pytest.mark.asyncio(loop_scope="session")
async def test_a(session_loop_fixture):
global loop
assert asyncio.get_running_loop() is loop
"""
)
)

result = pytester.runpytest("--asyncio-mode=auto")
result.assert_outcomes(passed=1)

0 comments on commit 5272cc4

Please sign in to comment.