diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 620a65cc..a6851845 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: - name: Run pre-commit checks run: pre-commit run --all-files --show-diff-on-failure - name: Install check-wheel-content, and twine - run: python -m pip install build check-wheel-contents tox twine + run: python -m pip install build check-wheel-contents twine - name: Build package run: python -m build - name: List result diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f83159c1..2ad9e702 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.6.0 hooks: - id: check-merge-conflict exclude: rst$ - repo: https://github.com/asottile/yesqa - rev: v1.4.0 + rev: v1.5.0 hooks: - id: yesqa - repo: https://github.com/Zac-HD/shed - rev: 2024.1.1 + rev: 2024.3.1 hooks: - id: shed args: @@ -20,12 +20,12 @@ repos: - markdown - rst - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt - rev: 0.2.2 + rev: 0.2.3 hooks: - id: yamlfmt args: [--mapping, '2', --sequence, '2', --offset, '0'] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -37,25 +37,38 @@ repos: - id: check-yaml - id: debug-statements - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.11.1 hooks: - id: mypy exclude: ^(docs|tests)/.* additional_dependencies: - pytest - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.1.1 hooks: - id: flake8 language_version: python3 - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.9.0 + rev: v1.10.0 hooks: - id: python-use-type-annotations +- repo: https://github.com/rhysd/actionlint + rev: v1.7.1 + hooks: + - id: actionlint-docker + args: + - -ignore + - 'SC2155:' + - -ignore + - 'SC2086:' + - -ignore + - 'SC1004:' + stages: [manual] - repo: https://github.com/sirosen/check-jsonschema - rev: 0.19.2 + rev: 0.29.1 hooks: - id: check-github-actions ci: skip: + - actionlint-docker - check-github-actions diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d825e855..8ffb4b25 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,18 +1,29 @@ --- -# Read the Docs configuration file for Sphinx projects -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 + build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: '3.12' - -sphinx: - configuration: docs/source/conf.py - fail_on_warning: true - -python: - install: - - requirements: dependencies/default/constraints.txt - - requirements: dependencies/docs/constraints.txt + python: >- + 3.12 + commands: + - >- + PYTHONWARNINGS=error + python3 -Im venv "${READTHEDOCS_VIRTUALENV_PATH}" + - >- + PYTHONWARNINGS=error + "${READTHEDOCS_VIRTUALENV_PATH}"/bin/python -Im + pip install tox + - >- + PYTHONWARNINGS=error + "${READTHEDOCS_VIRTUALENV_PATH}"/bin/python -Im + tox -e docs --notest -vvvvv + - >- + PYTHONWARNINGS=error + "${READTHEDOCS_VIRTUALENV_PATH}"/bin/python -Im + tox -e docs --skip-pkg-install -q + -- + "${READTHEDOCS_OUTPUT}"/html + -b html + -D language=en diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt index 2ccf269e..1221d3b9 100644 --- a/dependencies/default/constraints.txt +++ b/dependencies/default/constraints.txt @@ -1,11 +1,11 @@ -attrs==23.2.0 -coverage==7.6.0 +attrs==24.2.0 +coverage==7.6.1 exceptiongroup==1.2.2 -hypothesis==6.108.2 +hypothesis==6.111.1 iniconfig==2.0.0 packaging==24.1 pluggy==1.5.0 -pytest==8.2.2 +pytest==8.3.2 sortedcontainers==2.4.0 tomli==2.0.1 typing_extensions==4.12.2 diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt index 3ac25aba..42cfc8d3 100644 --- a/dependencies/default/requirements.txt +++ b/dependencies/default/requirements.txt @@ -1,3 +1,3 @@ # Always adjust install_requires in setup.cfg and pytest-min-requirements.txt # when changing runtime dependencies -pytest >= 7.0.0,<9 +pytest >= 8.2,<9 diff --git a/dependencies/docs/constraints.txt b/dependencies/docs/constraints.txt index 4c187a1e..6df3c716 100644 --- a/dependencies/docs/constraints.txt +++ b/dependencies/docs/constraints.txt @@ -1,8 +1,8 @@ alabaster==0.7.16 -Babel==2.15.0 +Babel==2.16.0 certifi==2024.7.4 charset-normalizer==3.3.2 -docutils==0.18.1 +docutils==0.20.1 idna==3.7 imagesize==1.4.1 Jinja2==3.1.4 @@ -11,13 +11,13 @@ packaging==24.1 Pygments==2.18.0 requests==2.32.3 snowballstemmer==2.2.0 -Sphinx==7.3.7 +Sphinx==7.4.7 sphinx-rtd-theme==2.0.0 -sphinxcontrib-applehelp==1.0.8 -sphinxcontrib-devhelp==1.0.6 -sphinxcontrib-htmlhelp==2.0.5 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 sphinxcontrib-jquery==4.1 sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.7 -sphinxcontrib-serializinghtml==1.1.10 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 urllib3==2.2.2 diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt index 65e3addb..f01a0eb7 100644 --- a/dependencies/pytest-min/constraints.txt +++ b/dependencies/pytest-min/constraints.txt @@ -11,10 +11,10 @@ iniconfig==2.0.0 mock==5.1.0 nose==1.3.7 packaging==23.2 -pluggy==1.3.0 +pluggy==1.5.0 py==1.11.0 Pygments==2.16.1 -pytest==7.0.0 +pytest==8.2.0 requests==2.31.0 sortedcontainers==2.4.0 tomli==2.0.1 diff --git a/dependencies/pytest-min/requirements.txt b/dependencies/pytest-min/requirements.txt index 9fb33e96..918abfd5 100644 --- a/dependencies/pytest-min/requirements.txt +++ b/dependencies/pytest-min/requirements.txt @@ -1,3 +1,3 @@ # Always adjust install_requires in setup.cfg and requirements.txt # when changing minimum version dependencies -pytest[testing] == 7.0.0 +pytest[testing] == 8.2.0 diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d0c3cbf1..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/concepts.rst b/docs/concepts.rst similarity index 97% rename from docs/source/concepts.rst rename to docs/concepts.rst index 710c5365..be8b775b 100644 --- a/docs/source/concepts.rst +++ b/docs/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_function_scope_example.py b/docs/concepts_function_scope_example.py similarity index 100% rename from docs/source/concepts_function_scope_example.py rename to docs/concepts_function_scope_example.py diff --git a/docs/source/concepts_module_scope_example.py b/docs/concepts_module_scope_example.py similarity index 74% rename from docs/source/concepts_module_scope_example.py rename to docs/concepts_module_scope_example.py index 66972888..b83181b4 100644 --- a/docs/source/concepts_module_scope_example.py +++ b/docs/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/conf.py b/docs/conf.py similarity index 92% rename from docs/source/conf.py rename to docs/conf.py index 4bb6535d..62a48a45 100644 --- a/docs/source/conf.py +++ b/docs/conf.py @@ -6,10 +6,12 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import importlib.metadata + project = "pytest-asyncio" copyright = "2023, pytest-asyncio contributors" author = "Tin Tvrtković" -release = "v0.23.0" +release = importlib.metadata.version(project) # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/how-to-guides/change_default_fixture_loop.rst b/docs/how-to-guides/change_default_fixture_loop.rst new file mode 100644 index 00000000..b54fef8e --- /dev/null +++ b/docs/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/how-to-guides/change_fixture_loop.rst b/docs/how-to-guides/change_fixture_loop.rst new file mode 100644 index 00000000..c6c8b8e6 --- /dev/null +++ b/docs/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/how-to-guides/change_fixture_loop_example.py b/docs/how-to-guides/change_fixture_loop_example.py new file mode 100644 index 00000000..dc6d2ef3 --- /dev/null +++ b/docs/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/how-to-guides/class_scoped_loop_example.py similarity index 89% rename from docs/source/how-to-guides/class_scoped_loop_example.py rename to docs/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/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/how-to-guides/index.rst similarity index 79% rename from docs/source/how-to-guides/index.rst rename to docs/how-to-guides/index.rst index a61ead50..7b3c4f31 100644 --- a/docs/source/how-to-guides/index.rst +++ b/docs/how-to-guides/index.rst @@ -5,6 +5,10 @@ How-To Guides .. toctree:: :hidden: + migrate_from_0_21 + migrate_from_0_23 + 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/how-to-guides/migrate_from_0_21.rst b/docs/how-to-guides/migrate_from_0_21.rst new file mode 100644 index 00000000..a244ad1f --- /dev/null +++ b/docs/how-to-guides/migrate_from_0_21.rst @@ -0,0 +1,17 @@ +.. _how_to_guides/migrate_from_0_21: + +======================================== +How to migrate from pytest-asyncio v0.21 +======================================== +1. If your test suite re-implements the *event_loop* fixture, make sure the fixture implementations don't do anything besides creating a new asyncio event loop, yielding it, and closing it. +2. Convert all synchronous test cases requesting the *event_loop* fixture to asynchronous test cases. +3. Convert all synchronous fixtures requesting the *event_loop* fixture to asynchronous fixtures. +4. Remove the *event_loop* argument from all asynchronous test cases in favor of ``event_loop = asyncio.get_running_loop()``. +5. Remove the *event_loop* argument from all asynchronous fixtures in favor of ``event_loop = asyncio.get_running_loop()``. + +Go through all re-implemented *event_loop* fixtures in your test suite one by one, starting with the the fixture with the deepest nesting level and take note of the fixture scope: + +1. For all tests and fixtures affected by the re-implemented *event_loop* fixture, configure the *loop_scope* for async tests and fixtures to match the *event_loop* fixture scope. This can be done for each test and fixture individually using either the ``pytest.mark.asyncio(loop_scope="…")`` marker for async tests or ``@pytest_asyncio.fixture(loop_scope="…")`` for async fixtures. Alternatively, you can set the default loop scope for fixtures using the :ref:`asyncio_default_fixture_loop_scope ` configuration option. Snippets to mark all tests with the same *asyncio* marker, thus sharing the same loop scope, are present in the how-to section of the documentation. Depending on the homogeneity of your test suite, you may want a mixture of explicit decorators and default settings. +2. Remove the re-implemented *event_loop* fixture. + +If you haven't set the *asyncio_default_fixture_loop_scope* configuration option, yet, set it to *function* to silence the deprecation warning. diff --git a/docs/how-to-guides/migrate_from_0_23.rst b/docs/how-to-guides/migrate_from_0_23.rst new file mode 100644 index 00000000..1235f358 --- /dev/null +++ b/docs/how-to-guides/migrate_from_0_23.rst @@ -0,0 +1,8 @@ +======================================== +How to migrate from pytest-asyncio v0.23 +======================================== +The following steps assume that your test suite has no re-implementations of the *event_loop* fixture, nor explicit fixtures requests for it. If this isn't the case, please follow the :ref:`migration guide for pytest-asyncio v0.21. ` + +1. Explicitly set the *loop_scope* of async fixtures by replacing occurrences of ``@pytest.fixture(scope="…")`` and ``@pytest_asyncio.fixture(scope="…")`` with ``@pytest_asyncio.fixture(loop_scope="…", scope="…")`` such that *loop_scope* and *scope* are the same. If you use auto mode, resolve all import errors from missing imports of *pytest_asyncio*. If your async fixtures all use the same *loop_scope*, you may choose to set the *asyncio_default_fixture_loop_scope* configuration option to that loop scope, instead. +2. If you haven't set *asyncio_default_fixture_loop_scope*, set it to *function* to address the deprecation warning about the unset configuration option. +3. Change all occurrences of ``pytest.mark.asyncio(scope="…")`` to ``pytest.mark.asyncio(loop_scope="…")`` to address the deprecation warning about the *scope* argument to the *asyncio* marker. diff --git a/docs/source/how-to-guides/module_scoped_loop_example.py b/docs/how-to-guides/module_scoped_loop_example.py similarity index 82% rename from docs/source/how-to-guides/module_scoped_loop_example.py rename to docs/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/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/multiple_loops.rst b/docs/how-to-guides/multiple_loops.rst similarity index 100% rename from docs/source/how-to-guides/multiple_loops.rst rename to docs/how-to-guides/multiple_loops.rst diff --git a/docs/source/how-to-guides/multiple_loops_example.py b/docs/how-to-guides/multiple_loops_example.py similarity index 100% rename from docs/source/how-to-guides/multiple_loops_example.py rename to docs/how-to-guides/multiple_loops_example.py diff --git a/docs/how-to-guides/package_scoped_loop_example.py b/docs/how-to-guides/package_scoped_loop_example.py new file mode 100644 index 00000000..903e9c8c --- /dev/null +++ b/docs/how-to-guides/package_scoped_loop_example.py @@ -0,0 +1,3 @@ +import pytest + +pytestmark = pytest.mark.asyncio(loop_scope="package") diff --git a/docs/source/how-to-guides/run_class_tests_in_same_loop.rst b/docs/how-to-guides/run_class_tests_in_same_loop.rst similarity index 87% rename from docs/source/how-to-guides/run_class_tests_in_same_loop.rst rename to docs/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/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/how-to-guides/run_module_tests_in_same_loop.rst similarity index 87% rename from docs/source/how-to-guides/run_module_tests_in_same_loop.rst rename to docs/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/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/how-to-guides/run_package_tests_in_same_loop.rst similarity index 90% rename from docs/source/how-to-guides/run_package_tests_in_same_loop.rst rename to docs/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/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/how-to-guides/run_session_tests_in_same_loop.rst similarity index 91% rename from docs/source/how-to-guides/run_session_tests_in_same_loop.rst rename to docs/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/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/how-to-guides/session_scoped_loop_example.py similarity index 80% rename from docs/source/how-to-guides/session_scoped_loop_example.py rename to docs/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/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/how-to-guides/test_item_is_async.rst b/docs/how-to-guides/test_item_is_async.rst similarity index 100% rename from docs/source/how-to-guides/test_item_is_async.rst rename to docs/how-to-guides/test_item_is_async.rst diff --git a/docs/source/how-to-guides/test_item_is_async_example.py b/docs/how-to-guides/test_item_is_async_example.py similarity index 100% rename from docs/source/how-to-guides/test_item_is_async_example.py rename to docs/how-to-guides/test_item_is_async_example.py diff --git a/docs/source/how-to-guides/test_session_scoped_loop_example.py b/docs/how-to-guides/test_session_scoped_loop_example.py similarity index 100% rename from docs/source/how-to-guides/test_session_scoped_loop_example.py rename to docs/how-to-guides/test_session_scoped_loop_example.py diff --git a/docs/source/how-to-guides/uvloop.rst b/docs/how-to-guides/uvloop.rst similarity index 100% rename from docs/source/how-to-guides/uvloop.rst rename to docs/how-to-guides/uvloop.rst diff --git a/docs/source/index.rst b/docs/index.rst similarity index 100% rename from docs/source/index.rst rename to docs/index.rst diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index dc1312ab..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/source/reference/changelog.rst b/docs/reference/changelog.rst similarity index 95% rename from docs/source/reference/changelog.rst rename to docs/reference/changelog.rst index b62e5114..77cf3451 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -2,6 +2,15 @@ Changelog ========= +0.24.0 (2024-08-22) +=================== +- BREAKING: Updated minimum supported pytest version to v8.2.0 +- 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. +- Raises an error when passing `scope` or `loop_scope` as a positional argument to ``@pytest.mark.asyncio``. `#812 `_ +- Fixes a bug that caused module-scoped async fixtures to fail when reused in other modules `#862 `_ `#668 `_ + + 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/reference/configuration.rst similarity index 57% rename from docs/source/reference/configuration.rst rename to docs/reference/configuration.rst index 5d840c47..35c67302 100644 --- a/docs/source/reference/configuration.rst +++ b/docs/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/reference/decorators/index.rst b/docs/reference/decorators/index.rst new file mode 100644 index 00000000..0fcb7087 --- /dev/null +++ b/docs/reference/decorators/index.rst @@ -0,0 +1,22 @@ +.. _decorators/pytest_asyncio_fixture: + +========== +Decorators +========== +The ``@pytest_asyncio.fixture`` decorator allows coroutines and async generator functions to be used as pytest fixtures. + +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. + +Examples: + +.. include:: pytest_asyncio_fixture_example.py + :code: python + +*auto* mode automatically converts coroutines and async generator functions declared with the standard ``@pytest.fixture`` decorator to pytest-asyncio fixtures. diff --git a/docs/reference/decorators/pytest_asyncio_fixture_example.py b/docs/reference/decorators/pytest_asyncio_fixture_example.py new file mode 100644 index 00000000..3123f11d --- /dev/null +++ b/docs/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_example.py b/docs/reference/fixtures/event_loop_example.py similarity index 100% rename from docs/source/reference/fixtures/event_loop_example.py rename to docs/reference/fixtures/event_loop_example.py diff --git a/docs/source/reference/fixtures/event_loop_policy_example.py b/docs/reference/fixtures/event_loop_policy_example.py similarity index 88% rename from docs/source/reference/fixtures/event_loop_policy_example.py rename to docs/reference/fixtures/event_loop_policy_example.py index cfd7ab96..5fd87b73 100644 --- a/docs/source/reference/fixtures/event_loop_policy_example.py +++ b/docs/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/fixtures/event_loop_policy_parametrized_example.py b/docs/reference/fixtures/event_loop_policy_parametrized_example.py similarity index 100% rename from docs/source/reference/fixtures/event_loop_policy_parametrized_example.py rename to docs/reference/fixtures/event_loop_policy_parametrized_example.py diff --git a/docs/source/reference/fixtures/index.rst b/docs/reference/fixtures/index.rst similarity index 96% rename from docs/source/reference/fixtures/index.rst rename to docs/reference/fixtures/index.rst index 7b8dc818..37eec503 100644 --- a/docs/source/reference/fixtures/index.rst +++ b/docs/reference/fixtures/index.rst @@ -51,8 +51,7 @@ when several unused TCP ports are required in a test. .. code-block:: python def a_test(unused_tcp_port_factory): - port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory() - ... + _port1, _port2 = unused_tcp_port_factory(), unused_tcp_port_factory() unused_udp_port and unused_udp_port_factory =========================================== diff --git a/docs/source/reference/functions.rst b/docs/reference/functions.rst similarity index 100% rename from docs/source/reference/functions.rst rename to docs/reference/functions.rst diff --git a/docs/source/reference/index.rst b/docs/reference/index.rst similarity index 100% rename from docs/source/reference/index.rst rename to docs/reference/index.rst diff --git a/docs/source/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py b/docs/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py similarity index 100% rename from docs/source/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py rename to docs/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py diff --git a/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py b/docs/reference/markers/class_scoped_loop_strict_mode_example.py similarity index 88% rename from docs/source/reference/markers/class_scoped_loop_strict_mode_example.py rename to docs/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/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/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py similarity index 90% rename from docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py rename to docs/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/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/function_scoped_loop_pytestmark_strict_mode_example.py b/docs/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py similarity index 100% rename from docs/source/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py rename to docs/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py diff --git a/docs/source/reference/markers/function_scoped_loop_strict_mode_example.py b/docs/reference/markers/function_scoped_loop_strict_mode_example.py similarity index 100% rename from docs/source/reference/markers/function_scoped_loop_strict_mode_example.py rename to docs/reference/markers/function_scoped_loop_strict_mode_example.py diff --git a/docs/source/reference/markers/index.rst b/docs/reference/markers/index.rst similarity index 94% rename from docs/source/reference/markers/index.rst rename to docs/reference/markers/index.rst index a875b90d..6e9be1ca 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/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/reference/markers/module_scoped_loop_strict_mode_example.py similarity index 88% rename from docs/source/reference/markers/module_scoped_loop_strict_mode_example.py rename to docs/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/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/docs/source/how-to-guides/package_scoped_loop_example.py b/docs/source/how-to-guides/package_scoped_loop_example.py deleted file mode 100644 index f48c33f1..00000000 --- a/docs/source/how-to-guides/package_scoped_loop_example.py +++ /dev/null @@ -1,3 +0,0 @@ -import pytest - -pytestmark = pytest.mark.asyncio(scope="package") 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 deleted file mode 100644 index 5c96cf4b..00000000 --- a/docs/source/reference/decorators/index.rst +++ /dev/null @@ -1,15 +0,0 @@ -========== -Decorators -========== -Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. - -.. include:: fixture_strict_mode_example.py - :code: python - -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. - -*auto* mode automatically converts async fixtures declared with the -standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. diff --git a/docs/source/support.rst b/docs/support.rst similarity index 100% rename from docs/source/support.rst rename to docs/support.rst diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d3d1fcf7..178fcaa6 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -30,14 +30,17 @@ overload, ) +import pluggy import pytest from pytest import ( Class, Collector, Config, + FixtureDef, FixtureRequest, Function, Item, + Mark, Metafunc, Module, Package, @@ -61,12 +64,6 @@ FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] -# https://github.com/pytest-dev/pytest/commit/fb55615d5e999dd44306596f340036c195428ef1 -if pytest.version_tuple < (8, 0): - FixtureDef = Any -else: - from pytest import FixtureDef - class PytestAsyncioError(Exception): """Base class for exceptions raised by pytest-asyncio""" @@ -103,6 +100,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 +113,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 +130,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 +143,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 +165,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 +192,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 +218,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 +227,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,17 +242,14 @@ 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 - if scope == "function": - event_loop_fixture_id: Optional[str] = "event_loop" - else: - event_loop_node = _retrieve_scope_root(collector, scope) - event_loop_fixture_id = event_loop_node.stash.get( - # Type ignored because of non-optimal mypy inference. - _event_loop_fixture_id, # type: ignore[arg-type] - None, - ) - _make_asyncio_fixture_function(func) + scope = ( + getattr(func, "_loop_scope", None) + or default_loop_scope + or fixturedef.scope + ) + if scope == "function" and "event_loop" not in fixturedef.argnames: + fixturedef.argnames += ("event_loop",) + _make_asyncio_fixture_function(func, scope) function_signature = inspect.signature(func) if "event_loop" in function_signature.parameters: warnings.warn( @@ -239,49 +260,26 @@ def _preprocess_async_fixtures( f"instead." ) ) - assert event_loop_fixture_id - _inject_fixture_argnames( - fixturedef, - event_loop_fixture_id, - ) - _synchronize_async_fixture( - fixturedef, - event_loop_fixture_id, - ) + if "request" not in fixturedef.argnames: + fixturedef.argnames += ("request",) + _synchronize_async_fixture(fixturedef) assert _is_asyncio_fixture_function(fixturedef.func) processed_fixturedefs.add(fixturedef) -def _inject_fixture_argnames( - fixturedef: FixtureDef, event_loop_fixture_id: str -) -> None: - """ - Ensures that `request` and `event_loop` are arguments of the specified fixture. - """ - to_add = [] - for name in ("request", event_loop_fixture_id): - if name not in fixturedef.argnames: - to_add.append(name) - if to_add: - fixturedef.argnames += tuple(to_add) - - -def _synchronize_async_fixture( - fixturedef: FixtureDef, event_loop_fixture_id: str -) -> None: +def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: """ Wraps the fixture function of an async fixture in a synchronous function. """ if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) + _wrap_asyncgen_fixture(fixturedef) elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef, event_loop_fixture_id) + _wrap_async_fixture(fixturedef) def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], - event_loop_fixture_id: str, event_loop: asyncio.AbstractEventLoop, request: FixtureRequest, ) -> Dict[str, Any]: @@ -289,14 +287,12 @@ def _add_kwargs( ret = kwargs.copy() if "request" in sig.parameters: ret["request"] = request - if event_loop_fixture_id in sig.parameters: - ret[event_loop_fixture_id] = event_loop + if "event_loop" in sig.parameters: + ret["event_loop"] = event_loop return ret -def _perhaps_rebind_fixture_func( - func: _T, instance: Optional[Any], unittest: bool -) -> _T: +def _perhaps_rebind_fixture_func(func: _T, instance: Optional[Any]) -> _T: if instance is not None: # The fixture needs to be bound to the actual request.instance # so it is bound to the same object as the test method. @@ -305,28 +301,28 @@ def _perhaps_rebind_fixture_func( unbound, cls = func.__func__, type(func.__self__) # type: ignore except AttributeError: pass - # If unittest is true, the fixture is bound unconditionally. - # otherwise, only if the fixture was bound before to an instance of + # Only if the fixture was bound before to an instance of # the same type. - if unittest or (cls is not None and isinstance(instance, cls)): + if cls is not None and isinstance(instance, cls): func = unbound.__get__(instance) # type: ignore return func -def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: +def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @functools.wraps(fixture) def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - unittest = fixturedef.unittest if hasattr(fixturedef, "unittest") else False - func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) - event_loop = kwargs.pop(event_loop_fixture_id) - gen_obj = func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + func = _perhaps_rebind_fixture_func(fixture, request.instance) + event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( + request, func ) + event_loop = request.getfixturevalue(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id, None) + gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) async def setup(): - res = await gen_obj.__anext__() + res = await gen_obj.__anext__() # type: ignore[union-attr] return res def finalizer() -> None: @@ -334,7 +330,7 @@ def finalizer() -> None: async def async_finalizer() -> None: try: - await gen_obj.__anext__() + await gen_obj.__anext__() # type: ignore[union-attr] except StopAsyncIteration: pass else: @@ -348,27 +344,48 @@ async def async_finalizer() -> None: request.addfinalizer(finalizer) return result - fixturedef.func = _asyncgen_fixture_wrapper + fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc] -def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: +def _wrap_async_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @functools.wraps(fixture) def _async_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - unittest = False if pytest.version_tuple >= (8, 2) else fixturedef.unittest - func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) - event_loop = kwargs.pop(event_loop_fixture_id) + func = _perhaps_rebind_fixture_func(fixture, request.instance) + event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( + request, func + ) + event_loop = request.getfixturevalue(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id, None) async def setup(): - res = await func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + res = await func(**_add_kwargs(func, kwargs, event_loop, request)) return res return event_loop.run_until_complete(setup()) - fixturedef.func = _async_fixture_wrapper + fixturedef.func = _async_fixture_wrapper # type: ignore[misc] + + +def _get_event_loop_fixture_id_for_async_fixture( + request: FixtureRequest, func: Any +) -> str: + default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope") + loop_scope = ( + getattr(func, "_loop_scope", None) or default_loop_scope or request.scope + ) + if loop_scope == "function": + event_loop_fixture_id = "event_loop" + else: + event_loop_node = _retrieve_scope_root(request._pyfuncitem, loop_scope) + event_loop_fixture_id = event_loop_node.stash.get( + # Type ignored because of non-optimal mypy inference. + _event_loop_fixture_id, # type: ignore[arg-type] + "", + ) + assert event_loop_fixture_id + return event_loop_fixture_id class PytestAsyncioFunction(Function): @@ -523,21 +540,27 @@ def pytest_pycollect_makeitem_preprocess_async_fixtures( return None -# TODO: #778 Narrow down return type of function when dropping support for pytest 7 # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", hookwrapper=True) def pytest_pycollect_makeitem_convert_async_functions_to_subclass( collector: Union[pytest.Module, pytest.Class], name: str, obj: object -) -> Generator[None, Any, None]: +) -> Generator[None, pluggy.Result, None]: """ Converts coroutines and async generators collected as pytest.Functions 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 +723,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) @@ -733,11 +756,10 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: ) -# TODO: #778 Narrow down return type of function when dropping support for pytest 7 @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef, -) -> Generator[None, Any, None]: +) -> Generator[None, pluggy.Result, None]: """Adjust the event loop policy when an event loop is produced.""" if fixturedef.argname == "event_loop": # The use of a fixture finalizer is preferred over the @@ -882,6 +904,7 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: ) ) yield + return None def wrap_in_sync( @@ -932,7 +955,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] @@ -946,11 +969,39 @@ def pytest_runtest_setup(item: pytest.Item) -> None: obj, "is_hypothesis_test", False ): pytest.fail( - "test function `%r` is using Hypothesis, but pytest-asyncio " - "only works with Hypothesis 3.64.0 or later." % item + f"test function `{item!r}` is using Hypothesis, but pytest-asyncio " + "only works with Hypothesis 3.64.0 or later." ) +_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 asyncio_marker.args or ( + asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"} + ): + raise ValueError("mark.asyncio accepts only a keyword argument 'scope'.") + 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/setup.cfg b/setup.cfg index 9947cbe3..ac2f2adc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ include_package_data = True # Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies install_requires = - pytest >= 7.0.0,<9 + pytest >= 8.2,<9 [options.extras_require] testing = @@ -65,7 +65,7 @@ show_missing = true [tool:pytest] python_files = test_*.py *_example.py addopts = -rsx --tb=short -testpaths = docs/source tests +testpaths = docs tests asyncio_mode = auto junit_family=xunit2 filterwarnings = 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/async_fixtures/test_shared_module_fixture.py b/tests/async_fixtures/test_shared_module_fixture.py new file mode 100644 index 00000000..ff1cb62b --- /dev/null +++ b/tests/async_fixtures/test_shared_module_fixture.py @@ -0,0 +1,35 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester): + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest_asyncio + @pytest_asyncio.fixture(scope="module") + async def async_shared_module_fixture(): + return True + """ + ), + test_module_one=dedent( + """\ + import pytest + @pytest.mark.asyncio + async def test_shared_module_fixture_use_a(async_shared_module_fixture): + assert async_shared_module_fixture is True + """ + ), + test_module_two=dedent( + """\ + import pytest + @pytest.mark.asyncio + async def test_shared_module_fixture_use_b(async_shared_module_fixture): + assert async_shared_module_fixture is True + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) 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_invalid_arguments.py b/tests/markers/test_invalid_arguments.py new file mode 100644 index 00000000..a4f4b070 --- /dev/null +++ b/tests/markers/test_invalid_arguments.py @@ -0,0 +1,85 @@ +from textwrap import dedent + +import pytest + + +def test_no_error_when_scope_passed_as_sole_keyword_argument( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(loop_scope="session") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=1) + result.stdout.no_fnmatch_line("*ValueError*") + + +def test_error_when_scope_passed_as_positional_argument( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio("session") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + ["*ValueError: mark.asyncio accepts only a keyword argument*"] + ) + + +def test_error_when_wrong_keyword_argument_is_passed( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(cope="session") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + ["*ValueError: mark.asyncio accepts only a keyword argument 'scope'*"] + ) + + +def test_error_when_additional_keyword_arguments_are_passed( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(loop_scope="session", more="stuff") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + ["*ValueError: mark.asyncio accepts only a keyword argument*"] + ) 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) diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py index 12e791c1..e0df54de 100644 --- a/tests/test_is_async_test.py +++ b/tests/test_is_async_test.py @@ -1,6 +1,5 @@ from textwrap import dedent -import pytest from pytest import Pytester @@ -74,13 +73,7 @@ def pytest_collection_modifyitems(items): ) ) result = pytester.runpytest("--asyncio-mode=strict") - if pytest.version_tuple < (7, 2): - # Probably related to https://github.com/pytest-dev/pytest/pull/10012 - result.assert_outcomes(failed=1) - elif pytest.version_tuple < (8,): - result.assert_outcomes(skipped=1) - else: - result.assert_outcomes(failed=1) + result.assert_outcomes(failed=1) def test_returns_true_for_unmarked_coroutine_item_in_auto_mode(pytester: Pytester): diff --git a/tox.ini b/tox.ini index 665c2fff..79e96fa6 100644 --- a/tox.ini +++ b/tox.ini @@ -26,14 +26,48 @@ allowlist_externals = make [testenv:docs] +allowlist_externals = + git extras = docs deps = --requirement dependencies/docs/requirements.txt --constraint dependencies/docs/constraints.txt change_dir = docs -commands = make html -allowlist_externals = - make +description = Build The Docs with {basepython} +commands = + # Retrieve possibly missing commits: + -git fetch --unshallow + -git fetch --tags + + # Build the html docs with Sphinx: + {envpython} -Im sphinx \ + -j auto \ + {tty:--color} \ + -a \ + -T \ + -n \ + -W --keep-going \ + -d "{temp_dir}{/}.doctrees" \ + . \ + {posargs:"{envdir}{/}docs_out" -b html} + + # Print out the output docs dir and a way to serve html: + -{envpython} -c\ + 'import pathlib;\ + docs_dir = pathlib.Path(r"{envdir}") / "docs_out";\ + index_file = docs_dir / "index.html";\ + print("\n" + "=" * 120 +\ + f"\n\nOpen the documentation with:\n\n\ + \t$ python3 -Im webbrowser \N\{QUOTATION MARK\}file://\{index_file\}\N\{QUOTATION MARK\}\n\n\ + To serve docs, use\n\n\ + \t$ python3 -Im http.server --directory \ + \N\{QUOTATION MARK\}\{docs_dir\}\N\{QUOTATION MARK\} 0\n\n" +\ + "=" * 120)' +changedir = {toxinidir}{/}docs +isolated_build = true +passenv = + SSH_AUTH_SOCK +skip_install = false [gh-actions] python =