diff --git a/docs/how-to-guides/change_default_test_loop.rst b/docs/how-to-guides/change_default_test_loop.rst new file mode 100644 index 00000000..c5b625d1 --- /dev/null +++ b/docs/how-to-guides/change_default_test_loop.rst @@ -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. diff --git a/docs/how-to-guides/index.rst b/docs/how-to-guides/index.rst index 7b3c4f31..04276256 100644 --- a/docs/how-to-guides/index.rst +++ b/docs/how-to-guides/index.rst @@ -9,10 +9,10 @@ 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 - run_session_tests_in_same_loop multiple_loops uvloop test_item_is_async diff --git a/docs/how-to-guides/run_session_tests_in_same_loop.rst b/docs/how-to-guides/run_session_tests_in_same_loop.rst deleted file mode 100644 index f166fea0..00000000 --- a/docs/how-to-guides/run_session_tests_in_same_loop.rst +++ /dev/null @@ -1,10 +0,0 @@ -========================================================== -How to run all tests in the session in the same event loop -========================================================== -All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(loop_scope="session")``. -The easiest way to mark all tests is via a ``pytest_collection_modifyitems`` hook in the ``conftest.py`` at the root folder of your test suite. - -.. include:: session_scoped_loop_example.py - :code: python - -Note that this will also override *all* manually applied marks in *strict* mode. diff --git a/docs/how-to-guides/session_scoped_loop_example.py b/docs/how-to-guides/session_scoped_loop_example.py deleted file mode 100644 index 79cc8676..00000000 --- a/docs/how-to-guides/session_scoped_loop_example.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from pytest_asyncio import is_async_test - - -def pytest_collection_modifyitems(items): - pytest_asyncio_tests = (item for item in items if is_async_test(item)) - session_scope_marker = pytest.mark.asyncio(loop_scope="session") - for async_test in pytest_asyncio_tests: - async_test.add_marker(session_scope_marker, append=False) diff --git a/docs/how-to-guides/test_session_scoped_loop_example.py b/docs/how-to-guides/test_session_scoped_loop_example.py deleted file mode 100644 index 3d642246..00000000 --- a/docs/how-to-guides/test_session_scoped_loop_example.py +++ /dev/null @@ -1,63 +0,0 @@ -from pathlib import Path -from textwrap import dedent - -from pytest import Pytester - - -def test_session_scoped_loop_configuration_works_in_auto_mode( - pytester: Pytester, -): - session_wide_mark_conftest = ( - Path(__file__).parent / "session_scoped_loop_example.py" - ) - pytester.makeconftest(session_wide_mark_conftest.read_text()) - pytester.makepyfile( - dedent( - """\ - import asyncio - - session_loop = None - - async def test_store_loop(request): - global session_loop - session_loop = asyncio.get_running_loop() - - async def test_compare_loop(request): - global session_loop - assert asyncio.get_running_loop() is session_loop - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=auto") - result.assert_outcomes(passed=2) - - -def test_session_scoped_loop_configuration_works_in_strict_mode( - pytester: Pytester, -): - session_wide_mark_conftest = ( - Path(__file__).parent / "session_scoped_loop_example.py" - ) - pytester.makeconftest(session_wide_mark_conftest.read_text()) - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - session_loop = None - - @pytest.mark.asyncio - async def test_store_loop(request): - global session_loop - session_loop = asyncio.get_running_loop() - - @pytest.mark.asyncio - async def test_compare_loop(request): - global session_loop - assert asyncio.get_running_loop() is session_loop - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=2) diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index a28c9fd3..11b3d811 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -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 `_ + +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 `_ 0.25.1 (2025-01-02) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 35c67302..81175549 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -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 diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 2f028ae1..8f6d91dc 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -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 @@ -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), ] @@ -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) @@ -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] @@ -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"} @@ -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, diff --git a/tests/test_asyncio_mark.py b/tests/test_asyncio_mark.py index e22be989..81731adb 100644 --- a/tests/test_asyncio_mark.py +++ b/tests/test_asyncio_mark.py @@ -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)