Skip to content
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

feat: Configuration option for setting default loop_scope for tests #1035

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
=======================================================
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nothing short of great that you even included a how-to guide for the new approach, thanks!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a bit of copy-paste ;)

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.
2 changes: 1 addition & 1 deletion docs/how-to-guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ How-To Guides
migrate_from_0_23
change_fixture_loop
change_default_fixture_loop
change_default_test_loop
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can drop the "run_session_tests_in_same_loop" how-to guide. It's superseded by the configuration option. I cannot think of a use case where someone wants to use the pytest hook, instead of the config option.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How-to removed

run_class_tests_in_same_loop
run_module_tests_in_same_loop
run_package_tests_in_same_loop
run_session_tests_in_same_loop
multiple_loops
uvloop
test_item_is_async
Expand Down
10 changes: 0 additions & 10 deletions docs/how-to-guides/run_session_tests_in_same_loop.rst

This file was deleted.

10 changes: 0 additions & 10 deletions docs/how-to-guides/session_scoped_loop_example.py

This file was deleted.

63 changes: 0 additions & 63 deletions docs/how-to-guides/test_session_scoped_loop_example.py

This file was deleted.

6 changes: 5 additions & 1 deletion docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
Changelog
=========

0.25.2 (2025-01-08)
0.26.0 (UNRELEASED)
===================
- Adds configuration option that sets default event loop scope for all testss `#793 <https://github.com/pytest-dev/pytest-asyncio/issues/793>`_


0.25.2 (2025-01-08)
===================
- Call ``loop.shutdown_asyncgens()`` before closing the event loop to ensure async generators are closed in the same manner as ``asyncio.run`` does `#1034 <https://github.com/pytest-dev/pytest-asyncio/pull/1034>`_

0.25.1 (2025-01-02)
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 @@ -807,7 +819,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 @@ -1078,7 +1091,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 @@ -1108,7 +1122,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 @@ -1119,12 +1135,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)
Loading