diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst index 710c5365..be8b775b 100644 --- a/docs/source/concepts.rst +++ b/docs/source/concepts.rst @@ -2,6 +2,8 @@ Concepts ======== +.. _concepts/event_loops: + asyncio event loops =================== In order to understand how pytest-asyncio works, it helps to understand how pytest collectors work. @@ -32,7 +34,7 @@ You may notice that the individual levels resemble the possible `scopes of a pyt Pytest-asyncio provides one asyncio event loop for each pytest collector. By default, each test runs in the event loop provided by the *Function* collector, i.e. tests use the loop with the narrowest scope. This gives the highest level of isolation between tests. -If two or more tests share a common ancestor collector, the tests can be configured to run in their ancestor's loop by passing the appropriate *scope* keyword argument to the *asyncio* mark. +If two or more tests share a common ancestor collector, the tests can be configured to run in their ancestor's loop by passing the appropriate *loop_scope* keyword argument to the *asyncio* mark. For example, the following two tests use the asyncio event loop provided by the *Module* collector: .. include:: concepts_module_scope_example.py diff --git a/docs/source/concepts_module_scope_example.py b/docs/source/concepts_module_scope_example.py index 66972888..b83181b4 100644 --- a/docs/source/concepts_module_scope_example.py +++ b/docs/source/concepts_module_scope_example.py @@ -5,13 +5,13 @@ loop: asyncio.AbstractEventLoop -@pytest.mark.asyncio(scope="module") +@pytest.mark.asyncio(loop_scope="module") async def test_remember_loop(): global loop loop = asyncio.get_running_loop() -@pytest.mark.asyncio(scope="module") +@pytest.mark.asyncio(loop_scope="module") async def test_runs_in_a_loop(): global loop assert asyncio.get_running_loop() is loop diff --git a/docs/source/how-to-guides/change_default_fixture_loop.rst b/docs/source/how-to-guides/change_default_fixture_loop.rst new file mode 100644 index 00000000..b54fef8e --- /dev/null +++ b/docs/source/how-to-guides/change_default_fixture_loop.rst @@ -0,0 +1,24 @@ +========================================================== +How to change the default event loop scope of all fixtures +========================================================== +The :ref:`configuration/asyncio_default_fixture_loop_scope` configuration option sets the default event loop scope for asynchronous fixtures. The following code snippets configure all fixtures to run in a session-scoped loop by default: + +.. code-block:: ini + :caption: pytest.ini + + [pytest] + asyncio_default_fixture_loop_scope = session + +.. code-block:: toml + :caption: pyproject.toml + + [tool.pytest.ini_options] + asyncio_default_fixture_loop_scope = "session" + +.. code-block:: ini + :caption: setup.cfg + + [tool:pytest] + asyncio_default_fixture_loop_scope = session + +Please refer to :ref:`configuration/asyncio_default_fixture_loop_scope` for other valid scopes. diff --git a/docs/source/how-to-guides/change_fixture_loop.rst b/docs/source/how-to-guides/change_fixture_loop.rst new file mode 100644 index 00000000..c6c8b8e6 --- /dev/null +++ b/docs/source/how-to-guides/change_fixture_loop.rst @@ -0,0 +1,7 @@ +=============================================== +How to change the event loop scope of a fixture +=============================================== +The event loop scope of an asynchronous fixture is specified via the *loop_scope* keyword argument to :ref:`pytest_asyncio.fixture `. The following fixture runs in the module-scoped event loop: + +.. include:: change_fixture_loop_example.py + :code: python diff --git a/docs/source/how-to-guides/change_fixture_loop_example.py b/docs/source/how-to-guides/change_fixture_loop_example.py new file mode 100644 index 00000000..dc6d2ef3 --- /dev/null +++ b/docs/source/how-to-guides/change_fixture_loop_example.py @@ -0,0 +1,15 @@ +import asyncio + +import pytest + +import pytest_asyncio + + +@pytest_asyncio.fixture(loop_scope="module") +async def current_loop(): + return asyncio.get_running_loop() + + +@pytest.mark.asyncio(loop_scope="module") +async def test_runs_in_module_loop(current_loop): + assert current_loop is asyncio.get_running_loop() diff --git a/docs/source/how-to-guides/class_scoped_loop_example.py b/docs/source/how-to-guides/class_scoped_loop_example.py index 5419a7ab..7ffc4b1f 100644 --- a/docs/source/how-to-guides/class_scoped_loop_example.py +++ b/docs/source/how-to-guides/class_scoped_loop_example.py @@ -3,7 +3,7 @@ import pytest -@pytest.mark.asyncio(scope="class") +@pytest.mark.asyncio(loop_scope="class") class TestInOneEventLoopPerClass: loop: asyncio.AbstractEventLoop diff --git a/docs/source/how-to-guides/index.rst b/docs/source/how-to-guides/index.rst index a61ead50..4f03dd35 100644 --- a/docs/source/how-to-guides/index.rst +++ b/docs/source/how-to-guides/index.rst @@ -5,6 +5,8 @@ How-To Guides .. toctree:: :hidden: + change_fixture_loop + change_default_fixture_loop run_class_tests_in_same_loop run_module_tests_in_same_loop run_package_tests_in_same_loop diff --git a/docs/source/how-to-guides/module_scoped_loop_example.py b/docs/source/how-to-guides/module_scoped_loop_example.py index b4ef778c..38ba8bdc 100644 --- a/docs/source/how-to-guides/module_scoped_loop_example.py +++ b/docs/source/how-to-guides/module_scoped_loop_example.py @@ -2,7 +2,7 @@ import pytest -pytestmark = pytest.mark.asyncio(scope="module") +pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop diff --git a/docs/source/how-to-guides/package_scoped_loop_example.py b/docs/source/how-to-guides/package_scoped_loop_example.py index f48c33f1..903e9c8c 100644 --- a/docs/source/how-to-guides/package_scoped_loop_example.py +++ b/docs/source/how-to-guides/package_scoped_loop_example.py @@ -1,3 +1,3 @@ import pytest -pytestmark = pytest.mark.asyncio(scope="package") +pytestmark = pytest.mark.asyncio(loop_scope="package") diff --git a/docs/source/how-to-guides/run_class_tests_in_same_loop.rst b/docs/source/how-to-guides/run_class_tests_in_same_loop.rst index a265899c..2ba40683 100644 --- a/docs/source/how-to-guides/run_class_tests_in_same_loop.rst +++ b/docs/source/how-to-guides/run_class_tests_in_same_loop.rst @@ -1,7 +1,7 @@ ====================================================== How to run all tests in a class in the same event loop ====================================================== -All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="class")``. +All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(loop_scope="class")``. This is easily achieved by using the *asyncio* marker as a class decorator. .. include:: class_scoped_loop_example.py diff --git a/docs/source/how-to-guides/run_module_tests_in_same_loop.rst b/docs/source/how-to-guides/run_module_tests_in_same_loop.rst index e07eca2e..c07de737 100644 --- a/docs/source/how-to-guides/run_module_tests_in_same_loop.rst +++ b/docs/source/how-to-guides/run_module_tests_in_same_loop.rst @@ -1,7 +1,7 @@ ======================================================= How to run all tests in a module in the same event loop ======================================================= -All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="module")``. +All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(loop_scope="module")``. This is easily achieved by adding a `pytestmark` statement to your module. .. include:: module_scoped_loop_example.py diff --git a/docs/source/how-to-guides/run_package_tests_in_same_loop.rst b/docs/source/how-to-guides/run_package_tests_in_same_loop.rst index 24326ed1..0392693f 100644 --- a/docs/source/how-to-guides/run_package_tests_in_same_loop.rst +++ b/docs/source/how-to-guides/run_package_tests_in_same_loop.rst @@ -1,7 +1,7 @@ ======================================================== How to run all tests in a package in the same event loop ======================================================== -All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="package")``. +All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(loop_scope="package")``. Add the following code to the ``__init__.py`` of the test package: .. include:: package_scoped_loop_example.py diff --git a/docs/source/how-to-guides/run_session_tests_in_same_loop.rst b/docs/source/how-to-guides/run_session_tests_in_same_loop.rst index 75bcd71e..f166fea0 100644 --- a/docs/source/how-to-guides/run_session_tests_in_same_loop.rst +++ b/docs/source/how-to-guides/run_session_tests_in_same_loop.rst @@ -1,7 +1,7 @@ ========================================================== 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(scope="session")``. +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 diff --git a/docs/source/how-to-guides/session_scoped_loop_example.py b/docs/source/how-to-guides/session_scoped_loop_example.py index 5d877116..79cc8676 100644 --- a/docs/source/how-to-guides/session_scoped_loop_example.py +++ b/docs/source/how-to-guides/session_scoped_loop_example.py @@ -5,6 +5,6 @@ def pytest_collection_modifyitems(items): pytest_asyncio_tests = (item for item in items if is_async_test(item)) - session_scope_marker = pytest.mark.asyncio(scope="session") + 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/source/reference/changelog.rst b/docs/source/reference/changelog.rst index b62e5114..82987a05 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,12 @@ Changelog ========= +0.24.0 (UNRELEASED) +=================== +- Adds an optional `loop_scope` keyword argument to `pytest.mark.asyncio`. This argument controls which event loop is used to run the marked async test. `#706`_, `#871 `_ +- Deprecates the optional `scope` keyword argument to `pytest.mark.asyncio` for API consistency with ``pytest_asyncio.fixture``. Users are encouraged to use the `loop_scope` keyword argument, which does exactly the same. + + 0.23.8 (2024-07-17) =================== - Fixes a bug that caused duplicate markers in async tests `#813 `_ diff --git a/docs/source/reference/configuration.rst b/docs/source/reference/configuration.rst index 5d840c47..35c67302 100644 --- a/docs/source/reference/configuration.rst +++ b/docs/source/reference/configuration.rst @@ -2,6 +2,14 @@ Configuration ============= +.. _configuration/asyncio_default_fixture_loop_scope: + +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`` + +asyncio_mode +============ The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file `_: diff --git a/docs/source/reference/decorators/fixture_strict_mode_example.py b/docs/source/reference/decorators/fixture_strict_mode_example.py deleted file mode 100644 index 6442c103..00000000 --- a/docs/source/reference/decorators/fixture_strict_mode_example.py +++ /dev/null @@ -1,14 +0,0 @@ -import asyncio - -import pytest_asyncio - - -@pytest_asyncio.fixture -async def async_gen_fixture(): - await asyncio.sleep(0.1) - yield "a value" - - -@pytest_asyncio.fixture(scope="module") -async def async_fixture(): - return await asyncio.sleep(0.1) diff --git a/docs/source/reference/decorators/index.rst b/docs/source/reference/decorators/index.rst index 5c96cf4b..0fcb7087 100644 --- a/docs/source/reference/decorators/index.rst +++ b/docs/source/reference/decorators/index.rst @@ -1,15 +1,22 @@ +.. _decorators/pytest_asyncio_fixture: + ========== Decorators ========== -Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. +The ``@pytest_asyncio.fixture`` decorator allows coroutines and async generator functions to be used as pytest fixtures. -.. include:: fixture_strict_mode_example.py - :code: python +The decorator takes all arguments supported by `@pytest.fixture`. +Additionally, ``@pytest_asyncio.fixture`` supports the *loop_scope* keyword argument, which selects the event loop in which the fixture is run (see :ref:`concepts/event_loops`). +The default event loop scope is *function* scope. +Possible loop scopes are *session,* *package,* *module,* *class,* and *function*. + +The *loop_scope* of a fixture can be chosen independently from its caching *scope*. +However, the event loop scope must be larger or the same as the fixture's caching scope. +In other words, it's possible to reevaluate an async fixture multiple times within the same event loop, but it's not possible to switch out the running event loop in an async fixture. -All scopes are supported, but if you use a non-function scope you will need -to redefine the ``event_loop`` fixture to have the same or broader scope. -Async fixtures need the event loop, and so must have the same or narrower scope -than the ``event_loop`` fixture. +Examples: + +.. include:: pytest_asyncio_fixture_example.py + :code: python -*auto* mode automatically converts async fixtures declared with the -standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. +*auto* mode automatically converts coroutines and async generator functions declared with the standard ``@pytest.fixture`` decorator to pytest-asyncio fixtures. diff --git a/docs/source/reference/decorators/pytest_asyncio_fixture_example.py b/docs/source/reference/decorators/pytest_asyncio_fixture_example.py new file mode 100644 index 00000000..3123f11d --- /dev/null +++ b/docs/source/reference/decorators/pytest_asyncio_fixture_example.py @@ -0,0 +1,17 @@ +import pytest_asyncio + + +@pytest_asyncio.fixture +async def fixture_runs_in_fresh_loop_for_every_function(): ... + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def fixture_runs_in_session_loop_once_per_module(): ... + + +@pytest_asyncio.fixture(loop_scope="module", scope="module") +async def fixture_runs_in_module_loop_once_per_module(): ... + + +@pytest_asyncio.fixture(loop_scope="module") +async def fixture_runs_in_module_loop_once_per_function(): ... diff --git a/docs/source/reference/fixtures/event_loop_policy_example.py b/docs/source/reference/fixtures/event_loop_policy_example.py index cfd7ab96..5fd87b73 100644 --- a/docs/source/reference/fixtures/event_loop_policy_example.py +++ b/docs/source/reference/fixtures/event_loop_policy_example.py @@ -12,6 +12,6 @@ def event_loop_policy(request): return CustomEventLoopPolicy() -@pytest.mark.asyncio(scope="module") +@pytest.mark.asyncio(loop_scope="module") async def test_uses_custom_event_loop_policy(): assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) diff --git a/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py index 38b5689c..e75279d5 100644 --- a/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py +++ b/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py @@ -3,7 +3,7 @@ import pytest -@pytest.mark.asyncio(scope="class") +@pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop diff --git a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py index 538f1bd2..239f3968 100644 --- a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py +++ b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py @@ -5,7 +5,7 @@ import pytest_asyncio -@pytest.mark.asyncio(scope="class") +@pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst index a875b90d..6e9be1ca 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -18,7 +18,7 @@ Multiple async tests in a single class or module can be marked using |pytestmark The ``pytest.mark.asyncio`` marker can be omitted entirely in |auto mode|_ where the *asyncio* marker is added automatically to *async* test functions. By default, each test runs in it's own asyncio event loop. -Multiple tests can share the same event loop by providing a *scope* keyword argument to the *asyncio* mark. +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 following code example provides a shared event loop for all tests in `TestClassScopedLoop`: diff --git a/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py b/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py index 221d554e..cece90db 100644 --- a/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py +++ b/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py @@ -2,7 +2,7 @@ import pytest -pytestmark = pytest.mark.asyncio(scope="module") +pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 04a7faca..aaa1e8f3 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -38,6 +38,7 @@ FixtureRequest, Function, Item, + Mark, Metafunc, Module, Package, @@ -103,6 +104,12 @@ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None help="default value for --asyncio-mode", default="strict", ) + parser.addini( + "asyncio_default_fixture_loop_scope", + type="string", + help="default scope of the asyncio event loop used to execute async fixtures", + default=None, + ) @overload @@ -110,6 +117,7 @@ def fixture( fixture_function: FixtureFunction, *, scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + loop_scope: Union[_ScopeName, None] = ..., params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Union[ @@ -126,6 +134,7 @@ def fixture( fixture_function: None = ..., *, scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + loop_scope: Union[_ScopeName, None] = ..., params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Union[ @@ -138,17 +147,19 @@ def fixture( def fixture( - fixture_function: Optional[FixtureFunction] = None, **kwargs: Any + fixture_function: Optional[FixtureFunction] = None, + loop_scope: Union[_ScopeName, None] = None, + **kwargs: Any, ) -> Union[FixtureFunction, FixtureFunctionMarker]: if fixture_function is not None: - _make_asyncio_fixture_function(fixture_function) + _make_asyncio_fixture_function(fixture_function, loop_scope) return pytest.fixture(fixture_function, **kwargs) else: @functools.wraps(fixture) def inner(fixture_function: FixtureFunction) -> FixtureFunction: - return fixture(fixture_function, **kwargs) + return fixture(fixture_function, loop_scope=loop_scope, **kwargs) return inner @@ -158,11 +169,14 @@ def _is_asyncio_fixture_function(obj: Any) -> bool: return getattr(obj, "_force_asyncio_fixture", False) -def _make_asyncio_fixture_function(obj: Any) -> None: +def _make_asyncio_fixture_function( + obj: Any, loop_scope: Union[_ScopeName, None] +) -> None: if hasattr(obj, "__func__"): # instance method, check the function object obj = obj.__func__ obj._force_asyncio_fixture = True + obj._loop_scope = loop_scope def _is_coroutine_or_asyncgen(obj: Any) -> bool: @@ -182,8 +196,20 @@ def _get_asyncio_mode(config: Config) -> Mode: ) +_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET = """\ +The configuration option "asyncio_default_fixture_loop_scope" is unset. +The event loop scope for asynchronous fixtures will default to the fixture caching \ +scope. Future versions of pytest-asyncio will default the loop scope for asynchronous \ +fixtures to function scope. Set the default fixture loop scope explicitly in order to \ +avoid unexpected behavior in the future. Valid fixture loop scopes are: \ +"function", "class", "module", "package", "session" +""" + + def pytest_configure(config: Config) -> None: - """Inject documentation.""" + default_loop_scope = config.getini("asyncio_default_fixture_loop_scope") + if not default_loop_scope: + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) config.addinivalue_line( "markers", "asyncio: " @@ -196,7 +222,8 @@ 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) - return [f"asyncio: mode={mode}"] + default_loop_scope = config.getini("asyncio_default_fixture_loop_scope") + return [f"asyncio: mode={mode}, default_loop_scope={default_loop_scope}"] def _preprocess_async_fixtures( @@ -204,6 +231,7 @@ def _preprocess_async_fixtures( processed_fixturedefs: Set[FixtureDef], ) -> None: config = collector.config + default_loop_scope = config.getini("asyncio_default_fixture_loop_scope") asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") assert fixturemanager is not None @@ -218,7 +246,11 @@ def _preprocess_async_fixtures( # Ignore async fixtures without explicit asyncio mark in strict mode # This applies to pytest_trio fixtures, for example continue - scope = fixturedef.scope + scope = ( + getattr(func, "_loop_scope", None) + or default_loop_scope + or fixturedef.scope + ) if scope == "function": event_loop_fixture_id: Optional[str] = "event_loop" else: @@ -228,7 +260,7 @@ def _preprocess_async_fixtures( _event_loop_fixture_id, # type: ignore[arg-type] None, ) - _make_asyncio_fixture_function(func) + _make_asyncio_fixture_function(func, scope) function_signature = inspect.signature(func) if "event_loop" in function_signature.parameters: warnings.warn( @@ -535,9 +567,16 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( to AsyncFunction items. """ hook_result = yield - node_or_list_of_nodes: Union[ - pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None - ] = hook_result.get_result() + try: + node_or_list_of_nodes: Union[ + pytest.Item, + pytest.Collector, + List[Union[pytest.Item, pytest.Collector]], + None, + ] = hook_result.get_result() + except BaseException as e: + hook_result.force_exception(e) + return if not node_or_list_of_nodes: return if isinstance(node_or_list_of_nodes, Sequence): @@ -700,7 +739,7 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: marker = metafunc.definition.get_closest_marker("asyncio") if not marker: return - scope = marker.kwargs.get("scope", "function") + scope = _get_marked_loop_scope(marker) if scope == "function": return event_loop_node = _retrieve_scope_root(metafunc.definition, scope) @@ -933,7 +972,7 @@ def pytest_runtest_setup(item: pytest.Item) -> None: marker = item.get_closest_marker("asyncio") if marker is None: return - scope = marker.kwargs.get("scope", "function") + scope = _get_marked_loop_scope(marker) if scope != "function": parent_node = _retrieve_scope_root(item, scope) event_loop_fixture_id = parent_node.stash[_event_loop_fixture_id] @@ -952,6 +991,30 @@ def pytest_runtest_setup(item: pytest.Item) -> None: ) +_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR = """\ +An asyncio pytest marker defines both "scope" and "loop_scope", \ +but it should only use "loop_scope". +""" + +_MARKER_SCOPE_KWARG_DEPRECATION_WARNING = """\ +The "scope" keyword argument to the asyncio marker has been deprecated. \ +Please use the "loop_scope" argument instead. +""" + + +def _get_marked_loop_scope(asyncio_marker: Mark) -> _ScopeName: + assert asyncio_marker.name == "asyncio" + if "scope" in asyncio_marker.kwargs: + if "loop_scope" in asyncio_marker.kwargs: + 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" + ) + assert scope in {"function", "class", "module", "package", "session"} + return scope + + def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: node_type_by_scope = { "class": Class, diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index b4d2ac94..bc6826bb 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -4,13 +4,13 @@ import pytest -@pytest.mark.asyncio(scope="module") +@pytest.mark.asyncio(loop_scope="module") async def test_module_with_event_loop_finalizer(port_with_event_loop_finalizer): await asyncio.sleep(0.01) assert port_with_event_loop_finalizer -@pytest.mark.asyncio(scope="module") +@pytest.mark.asyncio(loop_scope="module") async def test_module_with_get_event_loop_finalizer(port_with_get_event_loop_finalizer): await asyncio.sleep(0.01) assert port_with_get_event_loop_finalizer diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 3c77bab0..baac5869 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -39,11 +39,11 @@ def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_functions( class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") async def test_remember_loop(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop """ @@ -62,7 +62,7 @@ def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_class( import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -87,7 +87,7 @@ def test_asyncio_mark_raises_when_class_scoped_is_request_without_class( import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") async def test_has_no_surrounding_class(): pass """ @@ -107,7 +107,7 @@ def test_asyncio_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestSuperClassWithMark: pass @@ -183,7 +183,7 @@ def test_asyncio_mark_respects_parametrized_loop_policies( def event_loop_policy(request): return request.param - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestWithDifferentLoopPolicies: async def test_parametrized_loop(self, request): pass @@ -205,7 +205,7 @@ def test_asyncio_mark_provides_class_scoped_loop_to_fixtures( import pytest import pytest_asyncio - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -242,7 +242,7 @@ async def async_fixture(self): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="function") + @pytest.mark.asyncio(loop_scope="function") async def test_runs_in_different_loop_as_fixture(self, async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -277,7 +277,7 @@ def sets_event_loop_to_none(self): return asyncio.run(asyncio.sleep(0)) # asyncio.run() sets the current event loop to None when finished - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") # parametrization may impact fixture ordering @pytest.mark.parametrize("n", (0, 1)) async def test_does_not_fail(self, sets_event_loop_to_none, n): @@ -297,7 +297,7 @@ def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_be """\ import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestClass: async def test_anything(self): pass diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py index eded4552..81260006 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -28,6 +28,64 @@ async def test_does_not_run_in_same_loop(): result.assert_outcomes(passed=2) +def test_loop_scope_function_provides_function_scoped_event_loop(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio(loop_scope="function") + + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + async def test_does_not_run_in_same_loop(): + global loop + assert asyncio.get_running_loop() is not loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_raises_when_scope_and_loop_scope_arguments_are_present(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="function", loop_scope="function") + async def test_raises(): + ... + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + + +def test_warns_when_scope_argument_is_present(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="function") + async def test_warns(): + ... + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1, warnings=2) + result.stdout.fnmatch_lines("*DeprecationWarning*") + + def test_function_scope_supports_explicit_event_loop_fixture_request( pytester: Pytester, ): diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 5cc6a2a7..5280ed7e 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -62,7 +62,7 @@ def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester import asyncio import pytest - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop @@ -94,7 +94,7 @@ def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( import asyncio import pytest - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") async def test_remember_loop(event_loop): pass @@ -126,7 +126,7 @@ class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") @pytest.fixture(scope="module") def event_loop_policy(): @@ -146,7 +146,7 @@ async def test_uses_custom_event_loop_policy(): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") async def test_does_not_use_custom_event_loop_policy(): assert not isinstance( @@ -170,7 +170,7 @@ def test_asyncio_mark_respects_parametrized_loop_policies( import pytest - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") @pytest.fixture( scope="module", @@ -202,7 +202,7 @@ def test_asyncio_mark_provides_module_scoped_loop_to_fixtures( import pytest import pytest_asyncio - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop @@ -239,7 +239,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestMixedScopes: async def test_runs_in_different_loop_as_fixture(self, async_fixture): global loop @@ -271,7 +271,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="function") + @pytest.mark.asyncio(loop_scope="function") async def test_runs_in_different_loop_as_fixture(async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -301,7 +301,7 @@ async def async_fixture(): loop = asyncio.get_running_loop() yield - @pytest.mark.asyncio(scope="function") + @pytest.mark.asyncio(loop_scope="function") async def test_runs_in_different_loop_as_fixture(async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -334,7 +334,7 @@ def sets_event_loop_to_none(): return asyncio.run(asyncio.sleep(0)) # asyncio.run() sets the current event loop to None when finished - @pytest.mark.asyncio(scope="module") + @pytest.mark.asyncio(loop_scope="module") # parametrization may impact fixture ordering @pytest.mark.parametrize("n", (0, 1)) async def test_does_not_fail(sets_event_loop_to_none, n): @@ -354,7 +354,7 @@ def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_be """\ import pytest - @pytest.mark.asyncio(scope="module") + @pytest.mark.asyncio(loop_scope="module") async def test_anything(): pass """ diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index c80289be..849967e8 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -22,7 +22,7 @@ def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pyteste from {package_name} import shared_module - @pytest.mark.asyncio(scope="package") + @pytest.mark.asyncio(loop_scope="package") async def test_remember_loop(): shared_module.loop = asyncio.get_running_loop() """ @@ -34,7 +34,7 @@ async def test_remember_loop(): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") async def test_this_runs_in_same_loop(): assert asyncio.get_running_loop() is shared_module.loop @@ -55,7 +55,7 @@ async def test_this_runs_in_same_loop(self): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") async def test_subpackage_runs_in_different_loop(): assert asyncio.get_running_loop() is not shared_module.loop @@ -76,7 +76,7 @@ def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( import asyncio import pytest - @pytest.mark.asyncio(scope="package") + @pytest.mark.asyncio(loop_scope="package") async def test_remember_loop(event_loop): pass """ @@ -118,7 +118,7 @@ class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") async def test_uses_custom_event_loop_policy(): assert isinstance( @@ -134,7 +134,7 @@ async def test_uses_custom_event_loop_policy(): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") async def test_also_uses_custom_event_loop_policy(): assert isinstance( @@ -159,7 +159,7 @@ def test_asyncio_mark_respects_parametrized_loop_policies( import pytest - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") @pytest.fixture( scope="package", @@ -215,7 +215,7 @@ async def my_fixture(): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="package") + pytestmark = pytest.mark.asyncio(loop_scope="package") async def test_runs_in_same_loop_as_fixture(my_fixture): assert asyncio.get_running_loop() is shared_module.loop @@ -245,7 +245,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="module") + @pytest.mark.asyncio(loop_scope="module") async def test_runs_in_different_loop_as_fixture(async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -275,7 +275,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestMixedScopes: async def test_runs_in_different_loop_as_fixture(self, async_fixture): global loop @@ -340,7 +340,7 @@ def sets_event_loop_to_none(): return asyncio.run(asyncio.sleep(0)) # asyncio.run() sets the current event loop to None when finished - @pytest.mark.asyncio(scope="package") + @pytest.mark.asyncio(loop_scope="package") # parametrization may impact fixture ordering @pytest.mark.parametrize("n", (0, 1)) async def test_does_not_fail(sets_event_loop_to_none, n): @@ -361,7 +361,7 @@ def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_be """\ import pytest - @pytest.mark.asyncio(scope="package") + @pytest.mark.asyncio(loop_scope="package") async def test_anything(): pass """ diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py index b8b747a0..7900ef48 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -21,7 +21,7 @@ def test_asyncio_mark_provides_session_scoped_loop_strict_mode(pytester: Pyteste from {package_name} import shared_module - @pytest.mark.asyncio(scope="session") + @pytest.mark.asyncio(loop_scope="session") async def test_remember_loop(): shared_module.loop = asyncio.get_running_loop() """ @@ -33,7 +33,7 @@ async def test_remember_loop(): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") async def test_this_runs_in_same_loop(): assert asyncio.get_running_loop() is shared_module.loop @@ -56,7 +56,7 @@ async def test_this_runs_in_same_loop(self): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") async def test_subpackage_runs_in_same_loop(): assert asyncio.get_running_loop() is shared_module.loop @@ -77,7 +77,7 @@ def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( import asyncio import pytest - @pytest.mark.asyncio(scope="session") + @pytest.mark.asyncio(loop_scope="session") async def test_remember_loop(event_loop): pass """ @@ -119,7 +119,7 @@ class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") async def test_uses_custom_event_loop_policy(): assert isinstance( @@ -135,7 +135,7 @@ async def test_uses_custom_event_loop_policy(): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") async def test_also_uses_custom_event_loop_policy(): assert isinstance( @@ -160,7 +160,7 @@ def test_asyncio_mark_respects_parametrized_loop_policies( import pytest - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") @pytest.fixture( scope="session", @@ -220,7 +220,7 @@ async def my_fixture(): from {package_name} import shared_module - pytestmark = pytest.mark.asyncio(scope="session") + pytestmark = pytest.mark.asyncio(loop_scope="session") async def test_runs_in_same_loop_as_fixture(my_fixture): assert asyncio.get_running_loop() is shared_module.loop @@ -250,7 +250,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="package") + @pytest.mark.asyncio(loop_scope="package") async def test_runs_in_different_loop_as_fixture(async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -280,7 +280,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="module") + @pytest.mark.asyncio(loop_scope="module") async def test_runs_in_different_loop_as_fixture(async_fixture): global loop assert asyncio.get_running_loop() is not loop @@ -310,7 +310,7 @@ async def async_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestMixedScopes: async def test_runs_in_different_loop_as_fixture(self, async_fixture): global loop @@ -405,7 +405,7 @@ def sets_event_loop_to_none(): return asyncio.run(asyncio.sleep(0)) # asyncio.run() sets the current event loop to None when finished - @pytest.mark.asyncio(scope="session") + @pytest.mark.asyncio(loop_scope="session") # parametrization may impact fixture ordering @pytest.mark.parametrize("n", (0, 1)) async def test_does_not_fail(sets_event_loop_to_none, n): @@ -425,7 +425,7 @@ def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_be """\ import pytest - @pytest.mark.asyncio(scope="session") + @pytest.mark.asyncio(loop_scope="session") async def test_anything(): pass """ diff --git a/tests/test_fixture_loop_scopes.py b/tests/test_fixture_loop_scopes.py new file mode 100644 index 00000000..f0271e59 --- /dev/null +++ b/tests/test_fixture_loop_scopes.py @@ -0,0 +1,136 @@ +from textwrap import dedent + +import pytest +from pytest import Pytester + + +@pytest.mark.parametrize( + "fixture_scope", ("session", "package", "module", "class", "function") +) +def test_loop_scope_session_is_independent_of_fixture_scope( + pytester: Pytester, + fixture_scope: str, +): + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop = None + + @pytest_asyncio.fixture(scope="{fixture_scope}", loop_scope="session") + async def fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="session") + async def test_runs_in_same_loop_as_fixture(fixture): + global loop + assert loop == asyncio.get_running_loop() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("default_loop_scope", ("function", "module", "session")) +def test_default_loop_scope_config_option_changes_fixture_loop_scope( + pytester: Pytester, + default_loop_scope: str, +): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_fixture_loop_scope = {default_loop_scope} + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def fixture_loop(): + return asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="{default_loop_scope}") + async def test_runs_in_fixture_loop(fixture_loop): + assert asyncio.get_running_loop() is fixture_loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_default_class_loop_scope_config_option_changes_fixture_loop_scope( + pytester: Pytester, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = class + """ + ) + ) + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + + class TestClass: + @pytest_asyncio.fixture + async def fixture_loop(self): + return asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="class") + async def test_runs_in_fixture_loop(self, fixture_loop): + assert asyncio.get_running_loop() is fixture_loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_default_package_loop_scope_config_option_changes_fixture_loop_scope( + pytester: Pytester, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = package + """ + ) + ) + pytester.makepyfile( + __init__="", + test_a=dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def fixture_loop(): + return asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="package") + async def test_runs_in_fixture_loop(fixture_loop): + assert asyncio.get_running_loop() is fixture_loop + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1)