Skip to content

Commit aa2339d

Browse files
committed
feat: Configuration option for setting default loop_scope for tests
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.
1 parent 2188cdb commit aa2339d

8 files changed

+141
-28
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
=======================================================
2+
How to change the default event loop scope of all tests
3+
=======================================================
4+
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:
5+
6+
.. code-block:: ini
7+
:caption: pytest.ini
8+
9+
[pytest]
10+
asyncio_default_test_loop_scope = session
11+
12+
.. code-block:: toml
13+
:caption: pyproject.toml
14+
15+
[tool.pytest.ini_options]
16+
asyncio_default_test_loop_scope = "session"
17+
18+
.. code-block:: ini
19+
:caption: setup.cfg
20+
21+
[tool:pytest]
22+
asyncio_default_test_loop_scope = session
23+
24+
Please refer to :ref:`configuration/asyncio_default_test_loop_scope` for other valid scopes.

docs/how-to-guides/index.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ How-To Guides
99
migrate_from_0_23
1010
change_fixture_loop
1111
change_default_fixture_loop
12+
change_default_test_loop
1213
run_class_tests_in_same_loop
1314
run_module_tests_in_same_loop
1415
run_package_tests_in_same_loop
15-
run_session_tests_in_same_loop
1616
multiple_loops
1717
uvloop
1818
test_item_is_async

docs/how-to-guides/run_session_tests_in_same_loop.rst

-10
This file was deleted.

docs/how-to-guides/session_scoped_loop_example.py

-10
This file was deleted.

docs/reference/changelog.rst

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
Changelog
33
=========
44

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

10+
0.25.2 (2025-01-08)
11+
===================
812
- 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>`_
913

1014
0.25.1 (2025-01-02)

docs/reference/configuration.rst

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ asyncio_default_fixture_loop_scope
88
==================================
99
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``
1010

11+
.. _configuration/asyncio_default_test_loop_scope:
12+
13+
asyncio_default_test_loop_scope
14+
===============================
15+
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``
16+
1117
asyncio_mode
1218
============
1319
The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file

pytest_asyncio/plugin.py

+28-6
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None
107107
help="default scope of the asyncio event loop used to execute async fixtures",
108108
default=None,
109109
)
110+
parser.addini(
111+
"asyncio_default_test_loop_scope",
112+
type="string",
113+
help="default scope of the asyncio event loop used to execute tests",
114+
default="function",
115+
)
110116

111117

112118
@overload
@@ -217,9 +223,15 @@ def pytest_configure(config: Config) -> None:
217223
def pytest_report_header(config: Config) -> list[str]:
218224
"""Add asyncio config to pytest header."""
219225
mode = _get_asyncio_mode(config)
220-
default_loop_scope = config.getini("asyncio_default_fixture_loop_scope")
226+
default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope")
227+
default_test_loop_scope = _get_default_test_loop_scope(config)
228+
header = [
229+
f"mode={mode}",
230+
f"asyncio_default_fixture_loop_scope={default_fixture_loop_scope}",
231+
f"asyncio_default_test_loop_scope={default_test_loop_scope}",
232+
]
221233
return [
222-
f"asyncio: mode={mode}, asyncio_default_fixture_loop_scope={default_loop_scope}"
234+
"asyncio: " + ", ".join(header),
223235
]
224236

225237

@@ -807,7 +819,8 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
807819
marker = metafunc.definition.get_closest_marker("asyncio")
808820
if not marker:
809821
return
810-
scope = _get_marked_loop_scope(marker)
822+
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
823+
scope = _get_marked_loop_scope(marker, default_loop_scope)
811824
if scope == "function":
812825
return
813826
event_loop_node = _retrieve_scope_root(metafunc.definition, scope)
@@ -1078,7 +1091,8 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
10781091
marker = item.get_closest_marker("asyncio")
10791092
if marker is None:
10801093
return
1081-
scope = _get_marked_loop_scope(marker)
1094+
default_loop_scope = _get_default_test_loop_scope(item.config)
1095+
scope = _get_marked_loop_scope(marker, default_loop_scope)
10821096
if scope != "function":
10831097
parent_node = _retrieve_scope_root(item, scope)
10841098
event_loop_fixture_id = parent_node.stash[_event_loop_fixture_id]
@@ -1108,7 +1122,9 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
11081122
"""
11091123

11101124

1111-
def _get_marked_loop_scope(asyncio_marker: Mark) -> _ScopeName:
1125+
def _get_marked_loop_scope(
1126+
asyncio_marker: Mark, default_loop_scope: _ScopeName
1127+
) -> _ScopeName:
11121128
assert asyncio_marker.name == "asyncio"
11131129
if asyncio_marker.args or (
11141130
asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"}
@@ -1119,12 +1135,18 @@ def _get_marked_loop_scope(asyncio_marker: Mark) -> _ScopeName:
11191135
raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR)
11201136
warnings.warn(PytestDeprecationWarning(_MARKER_SCOPE_KWARG_DEPRECATION_WARNING))
11211137
scope = asyncio_marker.kwargs.get("loop_scope") or asyncio_marker.kwargs.get(
1122-
"scope", "function"
1138+
"scope"
11231139
)
1140+
if scope is None:
1141+
scope = default_loop_scope
11241142
assert scope in {"function", "class", "module", "package", "session"}
11251143
return scope
11261144

11271145

1146+
def _get_default_test_loop_scope(config: Config) -> _ScopeName:
1147+
return config.getini("asyncio_default_test_loop_scope")
1148+
1149+
11281150
def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector:
11291151
node_type_by_scope = {
11301152
"class": Class,

tests/test_asyncio_mark.py

+77
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,80 @@ async def test_a():
146146
result.stdout.fnmatch_lines(
147147
["*Tests based on asynchronous generators are not supported*"]
148148
)
149+
150+
151+
def test_asyncio_marker_fallbacks_to_configured_default_loop_scope_if_not_set(
152+
pytester: Pytester,
153+
):
154+
pytester.makeini(
155+
dedent(
156+
"""\
157+
[pytest]
158+
asyncio_default_fixture_loop_scope = function
159+
asyncio_default_test_loop_scope = session
160+
"""
161+
)
162+
)
163+
164+
pytester.makepyfile(
165+
dedent(
166+
"""\
167+
import asyncio
168+
import pytest_asyncio
169+
import pytest
170+
171+
loop: asyncio.AbstractEventLoop
172+
173+
@pytest_asyncio.fixture(loop_scope="session", scope="session")
174+
async def session_loop_fixture():
175+
global loop
176+
loop = asyncio.get_running_loop()
177+
178+
async def test_a(session_loop_fixture):
179+
global loop
180+
assert asyncio.get_running_loop() is loop
181+
"""
182+
)
183+
)
184+
185+
result = pytester.runpytest("--asyncio-mode=auto")
186+
result.assert_outcomes(passed=1)
187+
188+
189+
def test_asyncio_marker_uses_marker_loop_scope_even_if_config_is_set(
190+
pytester: Pytester,
191+
):
192+
pytester.makeini(
193+
dedent(
194+
"""\
195+
[pytest]
196+
asyncio_default_fixture_loop_scope = function
197+
asyncio_default_test_loop_scope = module
198+
"""
199+
)
200+
)
201+
202+
pytester.makepyfile(
203+
dedent(
204+
"""\
205+
import asyncio
206+
import pytest_asyncio
207+
import pytest
208+
209+
loop: asyncio.AbstractEventLoop
210+
211+
@pytest_asyncio.fixture(loop_scope="session", scope="session")
212+
async def session_loop_fixture():
213+
global loop
214+
loop = asyncio.get_running_loop()
215+
216+
@pytest.mark.asyncio(loop_scope="session")
217+
async def test_a(session_loop_fixture):
218+
global loop
219+
assert asyncio.get_running_loop() is loop
220+
"""
221+
)
222+
)
223+
224+
result = pytester.runpytest("--asyncio-mode=auto")
225+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)