diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 620a65cc..e12ad385 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: env: - PYTHON_LATEST: 3.12 + PYTHON_LATEST: 3.13 jobs: lint: @@ -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 @@ -62,7 +62,7 @@ jobs: strategy: matrix: os: [ubuntu, windows] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 3.13.0-beta.3] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -70,7 +70,7 @@ jobs: if: "!endsWith(matrix.python-version, '-dev')" with: python-version: ${{ matrix.python-version }} - - uses: deadsnakes/action@v3.1.0 + - uses: deadsnakes/action@v3.2.0 if: endsWith(matrix.python-version, '-dev') with: python-version: ${{ matrix.python-version }} @@ -88,7 +88,8 @@ jobs: if: "!endsWith(matrix.os, 'windows')" with: name: coverage-python-${{ matrix.python-version }} - path: .coverage.* + path: coverage/coverage.* + if-no-files-found: error check: name: Check @@ -112,13 +113,14 @@ jobs: uses: actions/download-artifact@v4 with: pattern: coverage-* + path: coverage merge-multiple: true - name: Combine coverage data and create report run: | coverage combine coverage xml - name: Upload coverage report - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage.xml fail_ci_if_error: true @@ -149,8 +151,9 @@ jobs: run: | pandoc -s -o README.md README.rst - name: PyPI upload - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.12.4 with: + attestations: true packages-dir: dist password: ${{ secrets.PYPI_API_TOKEN }} - name: GitHub Release diff --git a/.gitignore b/.gitignore index 7dd9b771..5a568761 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ -.coverage -.coverage.* +coverage/ .pytest_cache nosetests.xml coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f83159c1..973ac3f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,21 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v5.0.0 hooks: - id: check-merge-conflict exclude: rst$ +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.2 + hooks: + - id: ruff + args: [--fix] - 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.10.1 hooks: - id: shed args: @@ -20,12 +25,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: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -37,25 +42,39 @@ repos: - id: check-yaml - id: debug-statements - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.15.0 hooks: - id: mypy exclude: ^(docs|tests)/.* additional_dependencies: - pytest -- repo: https://github.com/pycqa/flake8 - rev: 6.1.0 - 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.7 + 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.31.3 hooks: - id: check-github-actions +- repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.5.1 + hooks: + - id: pyproject-fmt + # https://pyproject-fmt.readthedocs.io/en/latest/#calculating-max-supported-python-version + additional_dependencies: [tox>=4.9] 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/Makefile b/Makefile index e1ef5d27..83c8ba81 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,11 @@ clean-pyc: ## remove Python file artifacts clean-test: ## remove test and coverage artifacts rm -fr .tox/ - rm -f .coverage + rm -fr coverage/ rm -fr htmlcov/ test: - coverage run --parallel-mode --omit */_version.py -m pytest + coverage run -m pytest install: pip install -U pre-commit diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt index 2ccf269e..db8d6faf 100644 --- a/dependencies/default/constraints.txt +++ b/dependencies/default/constraints.txt @@ -1,11 +1,11 @@ -attrs==23.2.0 -coverage==7.6.0 +attrs==25.3.0 +coverage==7.7.1 exceptiongroup==1.2.2 -hypothesis==6.108.2 -iniconfig==2.0.0 -packaging==24.1 +hypothesis==6.130.3 +iniconfig==2.1.0 +packaging==24.2 pluggy==1.5.0 -pytest==8.2.2 +pytest==8.3.5 sortedcontainers==2.4.0 -tomli==2.0.1 +tomli==2.2.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..85afd831 100644 --- a/dependencies/docs/constraints.txt +++ b/dependencies/docs/constraints.txt @@ -1,23 +1,23 @@ alabaster==0.7.16 -Babel==2.15.0 -certifi==2024.7.4 -charset-normalizer==3.3.2 -docutils==0.18.1 -idna==3.7 +Babel==2.17.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +docutils==0.21.2 +idna==3.10 imagesize==1.4.1 -Jinja2==3.1.4 -MarkupSafe==2.1.5 -packaging==24.1 -Pygments==2.18.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==24.2 +Pygments==2.19.1 requests==2.32.3 snowballstemmer==2.2.0 -Sphinx==7.3.7 -sphinx-rtd-theme==2.0.0 -sphinxcontrib-applehelp==1.0.8 -sphinxcontrib-devhelp==1.0.6 -sphinxcontrib-htmlhelp==2.0.5 +Sphinx==8.0.2 +sphinx-rtd-theme==3.0.2 +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 -urllib3==2.2.2 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +urllib3==2.3.0 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 92% rename from docs/source/concepts.rst rename to docs/concepts.rst index 710c5365..a25df036 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 @@ -45,8 +47,12 @@ Assigning neighboring tests to different event loop scopes is discouraged as it Test discovery modes ==================== -Pytest-asyncio provides two modes for test discovery, *strict* and *auto*. +Pytest-asyncio provides two modes for test discovery, *strict* and *auto*. This can be set through Pytest's ``--asyncio-mode`` command line flag, or through the configuration file: + +.. code-block:: toml + [tool.pytest.ini_options] + asyncio_mode = "auto" # or "strict" Strict mode ----------- 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_default_test_loop.rst b/docs/how-to-guides/change_default_test_loop.rst new file mode 100644 index 00000000..c5b625d1 --- /dev/null +++ b/docs/how-to-guides/change_default_test_loop.rst @@ -0,0 +1,24 @@ +======================================================= +How to change the default event loop scope of all tests +======================================================= +The :ref:`configuration/asyncio_default_test_loop_scope` configuration option sets the default event loop scope for asynchronous tests. The following code snippets configure all tests to run in a session-scoped loop by default: + +.. code-block:: ini + :caption: pytest.ini + + [pytest] + asyncio_default_test_loop_scope = session + +.. code-block:: toml + :caption: pyproject.toml + + [tool.pytest.ini_options] + asyncio_default_test_loop_scope = "session" + +.. code-block:: ini + :caption: setup.cfg + + [tool:pytest] + asyncio_default_test_loop_scope = session + +Please refer to :ref:`configuration/asyncio_default_test_loop_scope` for other valid scopes. diff --git a/docs/how-to-guides/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 73% rename from docs/source/how-to-guides/index.rst rename to docs/how-to-guides/index.rst index a61ead50..04276256 100644 --- a/docs/source/how-to-guides/index.rst +++ b/docs/how-to-guides/index.rst @@ -5,10 +5,14 @@ How-To Guides .. toctree:: :hidden: + migrate_from_0_21 + migrate_from_0_23 + change_fixture_loop + change_default_fixture_loop + change_default_test_loop run_class_tests_in_same_loop run_module_tests_in_same_loop run_package_tests_in_same_loop - run_session_tests_in_same_loop multiple_loops uvloop test_item_is_async diff --git a/docs/how-to-guides/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/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/uvloop.rst b/docs/how-to-guides/uvloop.rst similarity index 69% rename from docs/source/how-to-guides/uvloop.rst rename to docs/how-to-guides/uvloop.rst index 889c0f9d..a796bea7 100644 --- a/docs/source/how-to-guides/uvloop.rst +++ b/docs/how-to-guides/uvloop.rst @@ -2,7 +2,7 @@ How to test with uvloop ======================= -Redefinig the *event_loop_policy* fixture will parametrize all async tests. The following example causes all async tests to run multiple times, once for each event loop in the fixture parameters: +Redefining the *event_loop_policy* fixture will parametrize all async tests. The following example causes all async tests to run multiple times, once for each event loop in the fixture parameters: Replace the default event loop policy in your *conftest.py:* .. code-block:: python 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 85% rename from docs/source/reference/changelog.rst rename to docs/reference/changelog.rst index b62e5114..11c35a1b 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -2,6 +2,48 @@ Changelog ========= +0.26.0 (2025-03-25) +=================== +- Adds configuration option that sets default event loop scope for all tests `#793 `_ +- Improved type annotations for ``pytest_asyncio.fixture`` `#1045 `_ +- Added ``typing-extensions`` as additional dependency for Python ``<3.10`` `#1045 `_ + + +0.25.3 (2025-01-28) +=================== +- Avoid errors in cleanup of async generators when event loop is already closed `#1040 `_ + + +0.25.2 (2025-01-08) +=================== +- Call ``loop.shutdown_asyncgens()`` before closing the event loop to ensure async generators are closed in the same manner as ``asyncio.run`` does `#1034 `_ + + +0.25.1 (2025-01-02) +=================== +- Fixes an issue that caused a broken event loop when a function-scoped test was executed in between two tests with wider loop scope `#950 `_ +- Improves test collection speed in auto mode `#1020 `_ +- Corrects the warning that is emitted upon redefining the event_loop fixture + + +0.25.0 (2024-12-13) +=================== +- Deprecated: Added warning when asyncio test requests async ``@pytest.fixture`` in strict mode. This will become an error in a future version of flake8-asyncio. `#979 `_ +- Updates the error message about `pytest.mark.asyncio`'s `scope` keyword argument to say `loop_scope` instead. `#1004 `_ +- Verbose log displays correct parameter name: asyncio_default_fixture_loop_scope `#990 `_ +- Propagates `contextvars` set in async fixtures to other fixtures and tests on Python 3.11 and above. `#1008 `_ + + +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 `_ +- Added the ``asyncio_default_fixture_loop_scope`` configuration option `c74d1c3 `_ + + 0.23.8 (2024-07-17) =================== - Fixes a bug that caused duplicate markers in async tests `#813 `_ diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst new file mode 100644 index 00000000..81175549 --- /dev/null +++ b/docs/reference/configuration.rst @@ -0,0 +1,35 @@ +============= +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`` + +.. _configuration/asyncio_default_test_loop_scope: + +asyncio_default_test_loop_scope +=============================== +Determines the default event loop scope of asynchronous tests. When this configuration option is unset, it default to function scope. Possible values are: ``function``, ``class``, ``module``, ``package``, ``session`` + +asyncio_mode +============ +The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file +`_: + +.. code-block:: ini + + # pytest.ini + [pytest] + asyncio_mode = auto + +The value can also be set via the ``--asyncio-mode`` command-line option: + +.. code-block:: bash + + $ pytest tests --asyncio-mode=strict + + +If the asyncio mode is set in both the pytest configuration file and the command-line option, the command-line option takes precedence. If no asyncio mode is specified, the mode defaults to `strict`. 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 82% rename from docs/source/reference/fixtures/index.rst rename to docs/reference/fixtures/index.rst index 7b8dc818..04953783 100644 --- a/docs/source/reference/fixtures/index.rst +++ b/docs/reference/fixtures/index.rst @@ -4,6 +4,13 @@ Fixtures event_loop ========== +*This fixture is deprecated.* + +*If you want to request an asyncio event loop with a scope other than function +scope, use the "loop_scope" argument to* :ref:`reference/markers/asyncio` *when marking the tests. +If you want to return different types of event loops, use the* :ref:`reference/fixtures/event_loop_policy` +*fixture.* + Creates a new asyncio event loop based on the current event loop policy. The new loop is available as the return value of this fixture for synchronous functions, or via `asyncio.get_running_loop `__ for asynchronous functions. The event loop is closed when the fixture scope ends. @@ -12,7 +19,7 @@ The fixture scope defaults to ``function`` scope. .. include:: event_loop_example.py :code: python -Note that, when using the ``event_loop`` fixture, you need to interact with the event loop using methods like ``event_loop.run_until_complete``. If you want to *await* code inside your test function, you need to write a coroutine and use it as a test function. The `asyncio <#pytest-mark-asyncio>`__ marker +Note that, when using the ``event_loop`` fixture, you need to interact with the event loop using methods like ``event_loop.run_until_complete``. If you want to *await* code inside your test function, you need to write a coroutine and use it as a test function. The :ref:`asyncio ` marker is used to mark coroutines that should be treated as test functions. If you need to change the type of the event loop, prefer setting a custom event loop policy over redefining the ``event_loop`` fixture. @@ -20,6 +27,8 @@ If you need to change the type of the event loop, prefer setting a custom event If the ``pytest.mark.asyncio`` decorator is applied to a test function, the ``event_loop`` fixture will be requested automatically by the test function. +.. _reference/fixtures/event_loop_policy: + event_loop_policy ================= Returns the event loop policy used to create asyncio event loops. @@ -51,8 +60,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 79% 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..6fff0af8 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,11 +5,11 @@ import pytest_asyncio -@pytest.mark.asyncio(scope="class") +@pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="class") + @pytest_asyncio.fixture(loop_scope="class") async def my_fixture(self): TestClassScopedLoop.loop = asyncio.get_running_loop() 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 88% rename from docs/source/reference/markers/index.rst rename to docs/reference/markers/index.rst index a875b90d..e7d700c9 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/reference/markers/index.rst @@ -2,6 +2,8 @@ Markers ======= +.. _reference/markers/asyncio: + ``pytest.mark.asyncio`` ======================= A coroutine or async generator with this marker is treated as a test function by pytest. @@ -18,14 +20,14 @@ 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`: .. include:: class_scoped_loop_strict_mode_example.py :code: python -Requesting class scope with the test being part of a class will give a *UsageError*. +If you request class scope for a test that is not part of a class, it will result in a *UsageError*. Similar to class-scoped event loops, a module-scoped loop is provided when setting mark's scope to *module:* .. include:: module_scoped_loop_strict_mode_example.py 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/how-to-guides/run_session_tests_in_same_loop.rst b/docs/source/how-to-guides/run_session_tests_in_same_loop.rst deleted file mode 100644 index 75bcd71e..00000000 --- a/docs/source/how-to-guides/run_session_tests_in_same_loop.rst +++ /dev/null @@ -1,10 +0,0 @@ -========================================================== -How to run all tests in the session in the same event loop -========================================================== -All tests can be run inside the same event loop by marking them with ``pytest.mark.asyncio(scope="session")``. -The easiest way to mark all tests is via a ``pytest_collection_modifyitems`` hook in the ``conftest.py`` at the root folder of your test suite. - -.. include:: session_scoped_loop_example.py - :code: python - -Note that this will also override *all* manually applied marks in *strict* mode. diff --git a/docs/source/how-to-guides/session_scoped_loop_example.py b/docs/source/how-to-guides/session_scoped_loop_example.py deleted file mode 100644 index 5d877116..00000000 --- a/docs/source/how-to-guides/session_scoped_loop_example.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from pytest_asyncio import is_async_test - - -def pytest_collection_modifyitems(items): - pytest_asyncio_tests = (item for item in items if is_async_test(item)) - session_scope_marker = pytest.mark.asyncio(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_session_scoped_loop_example.py b/docs/source/how-to-guides/test_session_scoped_loop_example.py deleted file mode 100644 index 3d642246..00000000 --- a/docs/source/how-to-guides/test_session_scoped_loop_example.py +++ /dev/null @@ -1,63 +0,0 @@ -from pathlib import Path -from textwrap import dedent - -from pytest import Pytester - - -def test_session_scoped_loop_configuration_works_in_auto_mode( - pytester: Pytester, -): - session_wide_mark_conftest = ( - Path(__file__).parent / "session_scoped_loop_example.py" - ) - pytester.makeconftest(session_wide_mark_conftest.read_text()) - pytester.makepyfile( - dedent( - """\ - import asyncio - - session_loop = None - - async def test_store_loop(request): - global session_loop - session_loop = asyncio.get_running_loop() - - async def test_compare_loop(request): - global session_loop - assert asyncio.get_running_loop() is session_loop - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=auto") - result.assert_outcomes(passed=2) - - -def test_session_scoped_loop_configuration_works_in_strict_mode( - pytester: Pytester, -): - session_wide_mark_conftest = ( - Path(__file__).parent / "session_scoped_loop_example.py" - ) - pytester.makeconftest(session_wide_mark_conftest.read_text()) - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - session_loop = None - - @pytest.mark.asyncio - async def test_store_loop(request): - global session_loop - session_loop = asyncio.get_running_loop() - - @pytest.mark.asyncio - async def test_compare_loop(request): - global session_loop - assert asyncio.get_running_loop() is session_loop - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=2) diff --git a/docs/source/reference/configuration.rst b/docs/source/reference/configuration.rst deleted file mode 100644 index 5d840c47..00000000 --- a/docs/source/reference/configuration.rst +++ /dev/null @@ -1,21 +0,0 @@ -============= -Configuration -============= - -The pytest-asyncio mode can be set by the ``asyncio_mode`` configuration option in the `configuration file -`_: - -.. code-block:: ini - - # pytest.ini - [pytest] - asyncio_mode = auto - -The value can also be set via the ``--asyncio-mode`` command-line option: - -.. code-block:: bash - - $ pytest tests --asyncio-mode=strict - - -If the asyncio mode is set in both the pytest configuration file and the command-line option, the command-line option takes precedence. If no asyncio mode is specified, the mode defaults to `strict`. 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/pyproject.toml b/pyproject.toml index 81540a53..b368b481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,139 @@ [build-system] +build-backend = "setuptools.build_meta" requires = [ - "setuptools>=51.0", - "wheel>=0.36", - "setuptools_scm[toml]>=6.2" + "setuptools>=77", + "setuptools-scm[toml]>=6.2", ] -build-backend = "setuptools.build_meta" + +[project] +name = "pytest-asyncio" +description = "Pytest support for asyncio" +readme.content-type = "text/x-rst" +readme.file = "README.rst" +license = "Apache-2.0" +license-files = [ + "LICENSE", +] +authors = [ + { name = "Tin Tvrtković ", email = "tinchester@gmail.com" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: AsyncIO", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Testing", + "Typing :: Typed", +] +dynamic = [ + "version", +] + +dependencies = [ + "pytest>=8.2,<9", + "typing-extensions>=4.12; python_version<'3.10'", +] +optional-dependencies.docs = [ + "sphinx>=5.3", + "sphinx-rtd-theme>=1", +] +optional-dependencies.testing = [ + "coverage>=6.2", + "hypothesis>=5.7.1", +] +urls."Bug Tracker" = "https://github.com/pytest-dev/pytest-asyncio/issues" +urls.Changelog = "https://pytest-asyncio.readthedocs.io/en/latest/reference/changelog.html" +urls.Documentation = "https://pytest-asyncio.readthedocs.io" +urls.Homepage = "https://github.com/pytest-dev/pytest-asyncio" +urls."Source Code" = "https://github.com/pytest-dev/pytest-asyncio" +entry-points.pytest11.asyncio = "pytest_asyncio.plugin" + +[tool.setuptools] +packages = [ + "pytest_asyncio", +] +include-package-data = true [tool.setuptools_scm] write_to = "pytest_asyncio/_version.py" + +[tool.ruff] +line-length = 88 +format.docstring-code-format = true +lint.select = [ + "B", # bugbear + "D", # pydocstyle + "E", # pycodestyle + "F", # pyflakes + "FA100", # add future annotations + "PGH004", # pygrep-hooks - Use specific rule codes when using noqa + "PIE", # flake8-pie + "PLE", # pylint error + "PYI", # flake8-pyi + "RUF", # ruff + "T100", # flake8-debugger + "UP", # pyupgrade + "W", # pycodestyle +] + +lint.ignore = [ + # bugbear ignore + "B028", # No explicit `stacklevel` keyword argument found + # pydocstyle ignore + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in `__init__` + "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D205", # 1 blank line required between summary line and description + "D209", # [*] Multi-line docstring closing quotes should be on a separate line + "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible. + "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood + "D402", # First line should not be the function's signature + "D404", # First word of the docstring should not be "This" + "D415", # First line should end with a period, question mark, or exclamation point +] + +[tool.pytest.ini_options] +python_files = [ + "test_*.py", + "*_example.py", +] +addopts = "-rsx --tb=short" +testpaths = [ + "docs", + "tests", +] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +junit_family = "xunit2" +filterwarnings = [ + "error", + "ignore:The event_loop fixture provided by pytest-asyncio has been redefined.*:DeprecationWarning", +] + +[tool.coverage.run] +source = [ + "pytest_asyncio", +] +branch = true +data_file = "coverage/coverage" +omit = [ + "*/_version.py", +] +parallel = true + +[tool.coverage.report] +show_missing = true diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index 08dca478..c25c1bf1 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,6 +1,8 @@ """The main point for importing pytest-asyncio items.""" -from ._version import version as __version__ # noqa +from __future__ import annotations + +from ._version import version as __version__ # noqa: F401 from .plugin import fixture, is_async_test __all__ = ("fixture", "is_async_test") diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d3d1fcf7..8a1d8733 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,43 +1,48 @@ """pytest-asyncio implementation.""" +from __future__ import annotations + import asyncio import contextlib +import contextvars import enum import functools import inspect import socket +import sys import warnings -from asyncio import AbstractEventLoopPolicy -from textwrap import dedent -from typing import ( - Any, +from asyncio import AbstractEventLoop, AbstractEventLoopPolicy +from collections.abc import ( AsyncIterator, Awaitable, - Callable, - Dict, + Coroutine as AbstractCoroutine, Generator, Iterable, Iterator, - List, - Literal, Mapping, - Optional, Sequence, - Set, - Type, +) +from textwrap import dedent +from typing import ( + Any, + Callable, + Literal, TypeVar, Union, overload, ) +import pluggy import pytest from pytest import ( Class, Collector, Config, + FixtureDef, FixtureRequest, Function, Item, + Mark, Metafunc, Module, Package, @@ -49,23 +54,17 @@ StashKey, ) -_ScopeName = Literal["session", "package", "module", "class", "function"] -_T = TypeVar("_T") +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec -SimpleFixtureFunction = TypeVar( - "SimpleFixtureFunction", bound=Callable[..., Awaitable[object]] -) -FactoryFixtureFunction = TypeVar( - "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[object]] -) -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 +_ScopeName = Literal["session", "package", "module", "class", "function"] +_T = TypeVar("_T") +_R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) +_P = ParamSpec("_P") +FixtureFunction = Callable[_P, _R] class PytestAsyncioError(Exception): @@ -103,52 +102,71 @@ 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, + ) + parser.addini( + "asyncio_default_test_loop_scope", + type="string", + help="default scope of the asyncio event loop used to execute tests", + default="function", + ) @overload def fixture( - fixture_function: FixtureFunction, + fixture_function: FixtureFunction[_P, _R], *, - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., - params: Optional[Iterable[object]] = ..., + scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., + loop_scope: _ScopeName | None = ..., + params: Iterable[object] | None = ..., autouse: bool = ..., - ids: Union[ - Iterable[Union[str, float, int, bool, None]], - Callable[[Any], Optional[object]], - None, - ] = ..., - name: Optional[str] = ..., -) -> FixtureFunction: ... + ids: ( + Iterable[str | float | int | bool | None] + | Callable[[Any], object | None] + | None + ) = ..., + name: str | None = ..., +) -> FixtureFunction[_P, _R]: ... @overload def fixture( fixture_function: None = ..., *, - scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., - params: Optional[Iterable[object]] = ..., + scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., + loop_scope: _ScopeName | None = ..., + params: Iterable[object] | None = ..., autouse: bool = ..., - ids: Union[ - Iterable[Union[str, float, int, bool, None]], - Callable[[Any], Optional[object]], - None, - ] = ..., - name: Optional[str] = None, -) -> FixtureFunctionMarker: ... + ids: ( + Iterable[str | float | int | bool | None] + | Callable[[Any], object | None] + | None + ) = ..., + name: str | None = None, +) -> Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]: ... def fixture( - fixture_function: Optional[FixtureFunction] = None, **kwargs: Any -) -> Union[FixtureFunction, FixtureFunctionMarker]: + fixture_function: FixtureFunction[_P, _R] | None = None, + loop_scope: _ScopeName | None = None, + **kwargs: Any, +) -> ( + FixtureFunction[_P, _R] + | Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] +): 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) + def inner(fixture_function: FixtureFunction[_P, _R]) -> FixtureFunction[_P, _R]: + return fixture(fixture_function, loop_scope=loop_scope, **kwargs) return inner @@ -158,15 +176,16 @@ 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: _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: - return asyncio.iscoroutinefunction(obj) or inspect.isasyncgenfunction(obj) + return inspect.iscoroutinefunction(obj) or inspect.isasyncgenfunction(obj) def _get_asyncio_mode(config: Config) -> Mode: @@ -175,15 +194,27 @@ def _get_asyncio_mode(config: Config) -> Mode: val = config.getini("asyncio_mode") try: return Mode(val) - except ValueError: + except ValueError as e: modes = ", ".join(m.value for m in Mode) raise pytest.UsageError( f"{val!r} is not a valid asyncio_mode. Valid modes: {modes}." - ) + ) from e + + +_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: " @@ -193,17 +224,27 @@ def pytest_configure(config: Config) -> None: @pytest.hookimpl(tryfirst=True) -def pytest_report_header(config: Config) -> List[str]: +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_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope") + default_test_loop_scope = _get_default_test_loop_scope(config) + header = [ + f"mode={mode}", + f"asyncio_default_fixture_loop_scope={default_fixture_loop_scope}", + f"asyncio_default_test_loop_scope={default_test_loop_scope}", + ] + return [ + "asyncio: " + ", ".join(header), + ] def _preprocess_async_fixtures( collector: Collector, - processed_fixturedefs: Set[FixtureDef], + 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 @@ -214,21 +255,18 @@ def _preprocess_async_fixtures( func ): continue - if not _is_asyncio_fixture_function(func) and asyncio_mode == Mode.STRICT: + if asyncio_mode == Mode.STRICT and not _is_asyncio_fixture_function(func): # 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,64 +277,37 @@ 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: - """ - Wraps the fixture function of an async fixture in a synchronous function. - """ +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, + kwargs: dict[str, Any], event_loop: asyncio.AbstractEventLoop, request: FixtureRequest, -) -> Dict[str, Any]: +) -> dict[str, Any]: sig = inspect.signature(func) 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: Any | None) -> _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,36 +316,42 @@ 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 + context = contextvars.copy_context() + setup_task = _create_task_in_context(event_loop, setup(), context) + result = event_loop.run_until_complete(setup_task) + + reset_contextvars = _apply_contextvar_changes(context) + def finalizer() -> None: """Yield again, to finalize.""" async def async_finalizer() -> None: try: - await gen_obj.__anext__() + await gen_obj.__anext__() # type: ignore[union-attr] except StopAsyncIteration: pass else: @@ -342,42 +359,134 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - event_loop.run_until_complete(async_finalizer()) + task = _create_task_in_context(event_loop, async_finalizer(), context) + event_loop.run_until_complete(task) + if reset_contextvars is not None: + reset_contextvars() - result = event_loop.run_until_complete(setup()) 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()) + context = contextvars.copy_context() + setup_task = _create_task_in_context(event_loop, setup(), context) + result = event_loop.run_until_complete(setup_task) + + # Copy the context vars modified by the setup task into the current + # context, and (if needed) add a finalizer to reset them. + # + # Note that this is slightly different from the behavior of a non-async + # fixture, which would rely on the fixture author to add a finalizer + # to reset the variables. In this case, the author of the fixture can't + # write such a finalizer because they have no way to capture the Context + # in which the setup function was run, so we need to do it for them. + reset_contextvars = _apply_contextvar_changes(context) + if reset_contextvars is not None: + request.addfinalizer(reset_contextvars) + + return result + + 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 + + +def _create_task_in_context( + loop: asyncio.AbstractEventLoop, + coro: AbstractCoroutine[Any, Any, _T], + context: contextvars.Context, +) -> asyncio.Task[_T]: + """ + Return an asyncio task that runs the coro in the specified context, + if possible. + + This allows fixture setup and teardown to be run as separate asyncio tasks, + while still being able to use context-manager idioms to maintain context + variables and make those variables visible to test functions. + + This is only fully supported on Python 3.11 and newer, as it requires + the API added for https://github.com/python/cpython/issues/91150. + On earlier versions, the returned task will use the default context instead. + """ + try: + return loop.create_task(coro, context=context) + except TypeError: + return loop.create_task(coro) + + +def _apply_contextvar_changes( + context: contextvars.Context, +) -> Callable[[], None] | None: + """ + Copy contextvar changes from the given context to the current context. + + If any contextvars were modified by the fixture, return a finalizer that + will restore them. + """ + context_tokens = [] + for var in context: + try: + if var.get() is context.get(var): + # This variable is not modified, so leave it as-is. + continue + except LookupError: + # This variable isn't yet set in the current context at all. + pass + token = var.set(context.get(var)) + context_tokens.append((var, token)) + + if not context_tokens: + return None + + def restore_contextvars(): + while context_tokens: + (var, token) = context_tokens.pop() + var.reset(token) - fixturedef.func = _async_fixture_wrapper + return restore_contextvars class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" @classmethod - def item_subclass_for( - cls, item: Function, / - ) -> Union[Type["PytestAsyncioFunction"], None]: + def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | None: """ Returns a subclass of PytestAsyncioFunction if there is a specialized subclass for the specified function item. @@ -430,7 +539,7 @@ class Coroutine(PytestAsyncioFunction): @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj - return asyncio.iscoroutinefunction(func) + return inspect.iscoroutinefunction(func) def runtest(self) -> None: self.obj = wrap_in_sync( @@ -495,7 +604,7 @@ def _can_substitute(item: Function) -> bool: return ( getattr(func, "is_hypothesis_test", False) # type: ignore[return-value] and getattr(func, "hypothesis", None) - and asyncio.iscoroutinefunction(func.hypothesis.inner_test) + and inspect.iscoroutinefunction(func.hypothesis.inner_test) ) def runtest(self) -> None: @@ -505,17 +614,15 @@ def runtest(self) -> None: super().runtest() -_HOLDER: Set[FixtureDef] = set() +_HOLDER: set[FixtureDef] = set() # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", tryfirst=True) def pytest_pycollect_makeitem_preprocess_async_fixtures( - collector: Union[pytest.Module, pytest.Class], name: str, obj: object -) -> Union[ - pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None -]: + collector: pytest.Module | pytest.Class, name: str, obj: object +) -> pytest.Item | pytest.Collector | list[pytest.Item | pytest.Collector] | None: """A pytest hook to collect asyncio coroutines.""" if not collector.funcnamefilter(name): return None @@ -523,21 +630,24 @@ 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]: + collector: pytest.Module | pytest.Class, name: str, obj: object +) -> 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: ( + pytest.Item | pytest.Collector | list[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): @@ -562,7 +672,7 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( _event_loop_fixture_id = StashKey[str]() -_fixture_scope_by_collector_type: Mapping[Type[pytest.Collector], _ScopeName] = { +_fixture_scope_by_collector_type: Mapping[type[pytest.Collector], _ScopeName] = { Class: "class", # Package is a subclass of module and the dict is used in isinstance checks # Therefore, the order matters and Package needs to appear before Module @@ -573,7 +683,7 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( # A stack used to push package-scoped loops during collection of a package # and pop those loops during collection of a Module -__package_loop_stack: List[Union[FixtureFunctionMarker, FixtureFunction]] = [] +__package_loop_stack: list[Callable[..., Any]] = [] @pytest.hookimpl @@ -614,12 +724,12 @@ def scoped_event_loop( event_loop_policy, ) -> Iterator[asyncio.AbstractEventLoop]: new_loop_policy = event_loop_policy - with _temporary_event_loop_policy(new_loop_policy): - loop = asyncio.new_event_loop() - loop.__pytest_asyncio = True # type: ignore[attr-defined] + with ( + _temporary_event_loop_policy(new_loop_policy), + _provide_event_loop() as loop, + ): asyncio.set_event_loop(loop) yield loop - loop.close() # @pytest.fixture does not register the fixture anywhere, so pytest doesn't # know it exists. We work around this by attaching the fixture function to the @@ -668,6 +778,19 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No try: yield finally: + # Try detecting user-created event loops that were left unclosed + # at the end of a test. + try: + current_loop: AbstractEventLoop | None = _get_event_loop_no_warn() + except RuntimeError: + current_loop = None + if current_loop is not None and not current_loop.is_closed(): + warnings.warn( + _UNCLOSED_EVENT_LOOP_WARNING % current_loop, + DeprecationWarning, + ) + current_loop.close() + asyncio.set_event_loop_policy(old_loop_policy) # When a test uses both a scoped event loop and the event_loop fixture, # the "_provide_clean_event_loop" finalizer of the event_loop fixture @@ -688,7 +811,7 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No Replacing the event_loop fixture with a custom implementation is deprecated and will lead to errors in the future. If you want to request an asyncio event loop with a scope other than function - scope, use the "scope" argument to the asyncio mark when marking the tests. + scope, use the "loop_scope" argument to the asyncio mark when marking the tests. If you want to return different types of event loops, use the event_loop_policy fixture. """ @@ -700,7 +823,8 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: marker = metafunc.definition.get_closest_marker("asyncio") if not marker: return - scope = marker.kwargs.get("scope", "function") + default_loop_scope = _get_default_test_loop_scope(metafunc.config) + scope = _get_marked_loop_scope(marker, default_loop_scope) if scope == "function": return event_loop_node = _retrieve_scope_root(metafunc.definition, scope) @@ -733,11 +857,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 @@ -756,7 +879,7 @@ def pytest_fixture_setup( # Weird behavior was observed when checking for an attribute of FixtureDef.func # Instead, we now check for a special attribute of the returned event loop fixture_filename = inspect.getsourcefile(fixturedef.func) - if not getattr(loop, "__original_fixture_loop", False): + if not _is_pytest_asyncio_loop(loop): _, fixture_line_number = inspect.getsourcelines(fixturedef.func) warnings.warn( _REDEFINED_EVENT_LOOP_FIXTURE_WARNING @@ -766,8 +889,7 @@ def pytest_fixture_setup( policy = asyncio.get_event_loop_policy() try: old_loop = _get_event_loop_no_warn(policy) - is_pytest_asyncio_loop = getattr(old_loop, "__pytest_asyncio", False) - if old_loop is not loop and not is_pytest_asyncio_loop: + if old_loop is not loop and not _is_pytest_asyncio_loop(old_loop): old_loop.close() except RuntimeError: # Either the current event loop has been set to None @@ -780,11 +902,20 @@ def pytest_fixture_setup( yield +def _make_pytest_asyncio_loop(loop: AbstractEventLoop) -> AbstractEventLoop: + loop.__pytest_asyncio = True # type: ignore[attr-defined] + return loop + + +def _is_pytest_asyncio_loop(loop: AbstractEventLoop) -> bool: + return getattr(loop, "__pytest_asyncio", False) + + def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None: """ - Regsiters the specified fixture finalizers in the fixture. + Registers the specified fixture finalizers in the fixture. - Finalizers need to specified in the exact order in which they should be invoked. + Finalizers need to be specified in the exact order in which they should be invoked. :param fixturedef: Fixture definition which finalizers should be added to :param finalizers: Finalizers to be added @@ -813,7 +944,7 @@ def _close_event_loop() -> None: loop = policy.get_event_loop() except RuntimeError: loop = None - if loop is not None: + if loop is not None and not _is_pytest_asyncio_loop(loop): if not loop.is_closed(): warnings.warn( _UNCLOSED_EVENT_LOOP_WARNING % loop, @@ -830,7 +961,7 @@ def _restore_policy(): loop = _get_event_loop_no_warn(previous_policy) except RuntimeError: loop = None - if loop: + if loop and not _is_pytest_asyncio_loop(loop): loop.close() asyncio.set_event_loop_policy(previous_policy) @@ -845,12 +976,17 @@ def _provide_clean_event_loop() -> None: # Note that we cannot set the loop to None, because get_event_loop only creates # a new loop, when set_event_loop has not been called. policy = asyncio.get_event_loop_policy() - new_loop = policy.new_event_loop() - policy.set_event_loop(new_loop) + try: + old_loop = _get_event_loop_no_warn(policy) + except RuntimeError: + old_loop = None + if old_loop is not None and not _is_pytest_asyncio_loop(old_loop): + new_loop = policy.new_event_loop() + policy.set_event_loop(new_loop) def _get_event_loop_no_warn( - policy: Optional[AbstractEventLoopPolicy] = None, + policy: AbstractEventLoopPolicy | None = None, ) -> asyncio.AbstractEventLoop: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -861,7 +997,7 @@ def _get_event_loop_no_warn( @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: +def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: """ Pytest hook called before a test case is run. @@ -870,7 +1006,32 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: """ if pyfuncitem.get_closest_marker("asyncio") is not None: if isinstance(pyfuncitem, PytestAsyncioFunction): - pass + asyncio_mode = _get_asyncio_mode(pyfuncitem.config) + for fixname, fixtures in pyfuncitem._fixtureinfo.name2fixturedefs.items(): + # name2fixturedefs is a dict between fixture name and a list of matching + # fixturedefs. The last entry in the list is closest and the one used. + func = fixtures[-1].func + if ( + asyncio_mode == Mode.STRICT + and _is_coroutine_or_asyncgen(func) + and not _is_asyncio_fixture_function(func) + ): + warnings.warn( + PytestDeprecationWarning( + f"asyncio test {pyfuncitem.name!r} requested async " + "@pytest.fixture " + f"{fixname!r} in strict mode. " + "You might want to use @pytest_asyncio.fixture or switch " + "to auto mode. " + "This will become an error in future versions of " + "flake8-asyncio." + ), + stacklevel=1, + ) + # no stacklevel points at the users code, so we set stacklevel=1 + # so it at least indicates that it's the plugin complaining. + # Pytest gives the test file & name in the warnings summary at least + else: pyfuncitem.warn( pytest.PytestWarning( @@ -882,14 +1043,16 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: ) ) yield + return None def wrap_in_sync( func: Callable[..., Awaitable[Any]], ): - """Return a sync wrapper around an async function executing it in the - current event loop.""" - + """ + Return a sync wrapper around an async function executing it in the + current event loop. + """ # if the function is already wrapped, we rewrap using the original one # not using __wrapped__ because the original function may already be # a wrapped one @@ -932,7 +1095,8 @@ 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") + default_loop_scope = _get_default_test_loop_scope(item.config) + scope = _get_marked_loop_scope(marker, default_loop_scope) if scope != "function": parent_node = _retrieve_scope_root(item, scope) event_loop_fixture_id = parent_node.stash[_event_loop_fixture_id] @@ -946,12 +1110,48 @@ 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." ) -def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: +_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, default_loop_scope: _ScopeName +) -> _ScopeName: + assert asyncio_marker.name == "asyncio" + if asyncio_marker.args or ( + asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"} + ): + raise ValueError("mark.asyncio accepts only a keyword argument 'loop_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" + ) + if scope is None: + scope = default_loop_scope + assert scope in {"function", "class", "module", "package", "session"} + return scope + + +def _get_default_test_loop_scope(config: Config) -> _ScopeName: + return config.getini("asyncio_default_test_loop_scope") + + +def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector: node_type_by_scope = { "class": Class, "module": Module, @@ -974,16 +1174,30 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" new_loop_policy = request.getfixturevalue(event_loop_policy.__name__) - asyncio.set_event_loop_policy(new_loop_policy) + with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop: + yield loop + + +@contextlib.contextmanager +def _provide_event_loop() -> Iterator[asyncio.AbstractEventLoop]: loop = asyncio.get_event_loop_policy().new_event_loop() # Add a magic value to the event loop, so pytest-asyncio can determine if the # event_loop fixture was overridden. Other implementations of event_loop don't # set this value. # The magic value must be set as part of the function definition, because pytest # seems to have multiple instances of the same FixtureDef or fixture function - loop.__original_fixture_loop = True # type: ignore[attr-defined] - yield loop - loop.close() + loop = _make_pytest_asyncio_loop(loop) + try: + yield loop + finally: + # cleanup the event loop if it hasn't been cleaned up already + if not loop.is_closed(): + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception as e: + warnings.warn(f"Error cleaning up asyncio loop: {e}", RuntimeWarning) + finally: + loop.close() @pytest.fixture(scope="session") @@ -991,12 +1205,9 @@ def _session_event_loop( request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy ) -> Iterator[asyncio.AbstractEventLoop]: new_loop_policy = event_loop_policy - with _temporary_event_loop_policy(new_loop_policy): - loop = asyncio.new_event_loop() - loop.__pytest_asyncio = True # type: ignore[attr-defined] + with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop: asyncio.set_event_loop(loop) yield loop - loop.close() @pytest.fixture(scope="session", autouse=True) diff --git a/setup.cfg b/setup.cfg index 9947cbe3..f68f54b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,76 +1,18 @@ [metadata] -name = pytest-asyncio +# Not everything is in in pyproject.toml because of this issue: +; Traceback (most recent call last): +; File "/tmp/build-env-rud8b5r6/lib/python3.12/site-packages/setuptools/config/expand.py", line 69, in __getattr__ +; return next( +; ^^^^^ +;StopIteration +; +;The above exception was the direct cause of the following exception: +; +;Traceback (most recent call last): +; File "/tmp/build-env-rud8b5r6/lib/python3.12/site-packages/setuptools/config/expand.py", line 183, in read_attr +; return getattr(StaticModule(module_name, spec), attr_name) +; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +; File "/tmp/build-env-rud8b5r6/lib/python3.12/site-packages/setuptools/config/expand.py", line 75, in __getattr__ +; raise AttributeError(f"{self.name} has no attribute {attr}") from e +;AttributeError: pytest_asyncio has no attribute __version__ version = attr: pytest_asyncio.__version__ -url = https://github.com/pytest-dev/pytest-asyncio -project_urls = - Documentation = https://pytest-asyncio.readthedocs.io - Changelog = https://pytest-asyncio.readthedocs.io/en/latest/reference/changelog.html - Source Code = https://github.com/pytest-dev/pytest-asyncio - Bug Tracker = https://github.com/pytest-dev/pytest-asyncio/issues -description = Pytest support for asyncio -long_description = file: README.rst -long_description_content_type = text/x-rst -author = Tin Tvrtković -author_email = tinchester@gmail.com -license = Apache 2.0 -license_files = LICENSE -classifiers = - Development Status :: 4 - Beta - - Intended Audience :: Developers - - License :: OSI Approved :: Apache Software License - - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: 3.13 - - Topic :: Software Development :: Testing - - Framework :: AsyncIO - Framework :: Pytest - Typing :: Typed - -[options] -python_requires = >=3.8 -packages = pytest_asyncio -include_package_data = True - -# Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies -install_requires = - pytest >= 7.0.0,<9 - -[options.extras_require] -testing = - coverage >= 6.2 - hypothesis >= 5.7.1 -docs = - sphinx >= 5.3 - sphinx-rtd-theme >= 1.0 - -[options.entry_points] -pytest11 = - asyncio = pytest_asyncio.plugin - -[coverage:run] -source = pytest_asyncio -branch = true - -[coverage:report] -show_missing = true - -[tool:pytest] -python_files = test_*.py *_example.py -addopts = -rsx --tb=short -testpaths = docs/source tests -asyncio_mode = auto -junit_family=xunit2 -filterwarnings = - error - ignore:The event_loop fixture provided by pytest-asyncio has been redefined.*:DeprecationWarning - -[flake8] -max-line-length = 88 diff --git a/tests/async_fixtures/test_async_fixtures.py b/tests/async_fixtures/test_async_fixtures.py index 40012962..16478539 100644 --- a/tests/async_fixtures/test_async_fixtures.py +++ b/tests/async_fixtures/test_async_fixtures.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import unittest.mock diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py new file mode 100644 index 00000000..ff79e17e --- /dev/null +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -0,0 +1,247 @@ +""" +Regression test for https://github.com/pytest-dev/pytest-asyncio/issues/127: +contextvars were not properly maintained among fixtures and tests. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +_prelude = dedent( + """ + import pytest + import pytest_asyncio + from contextlib import contextmanager + from contextvars import ContextVar + + _context_var = ContextVar("context_var") + + @contextmanager + def context_var_manager(value): + token = _context_var.set(value) + try: + yield + finally: + _context_var.reset(token) +""" +) + + +def test_var_from_sync_generator_propagates_to_async(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest.fixture + def var_fixture(): + with context_var_manager("value"): + yield + + @pytest_asyncio.fixture + async def check_var_fixture(var_fixture): + assert _context_var.get() == "value" + + @pytest.mark.asyncio + async def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_async_generator_propagates_to_sync(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + + @pytest.fixture + def check_var_fixture(var_fixture): + assert _context_var.get() == "value" + + @pytest.mark.asyncio + async def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_async_fixture_propagates_to_sync(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("value") + # Rely on async fixture teardown to reset the context var. + + @pytest.fixture + def check_var_fixture(var_fixture): + assert _context_var.get() == "value" + + def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_generator_reset_before_previous_fixture_cleanup(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def no_var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + with pytest.raises(LookupError): + _context_var.get() + + @pytest_asyncio.fixture + async def var_fixture(no_var_fixture): + with context_var_manager("value"): + yield + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_fixture_reset_before_previous_fixture_cleanup(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def no_var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + with pytest.raises(LookupError): + _context_var.get() + + @pytest_asyncio.fixture + async def var_fixture(no_var_fixture): + _context_var.set("value") + # Rely on async fixture teardown to reset the context var. + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_previous_value_restored_after_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture_1(): + with context_var_manager("value1"): + yield + assert _context_var.get() == "value1" + + @pytest_asyncio.fixture + async def var_fixture_2(var_fixture_1): + with context_var_manager("value2"): + yield + assert _context_var.get() == "value2" + + @pytest.mark.asyncio + async def test(var_fixture_2): + assert _context_var.get() == "value2" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_set_to_existing_value_ok(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + + @pytest_asyncio.fixture + async def same_var_fixture(var_fixture): + with context_var_manager(_context_var.get()): + yield + + @pytest.mark.asyncio + async def test(same_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/async_fixtures/test_async_fixtures_scope.py b/tests/async_fixtures/test_async_fixtures_scope.py index a25934a8..7fbed781 100644 --- a/tests/async_fixtures/test_async_fixtures_scope.py +++ b/tests/async_fixtures/test_async_fixtures_scope.py @@ -3,6 +3,8 @@ module-scoped too. """ +from __future__ import annotations + import asyncio import pytest diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index b4d2ac94..199ecbca 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -1,16 +1,20 @@ +from __future__ import annotations + import asyncio import functools import pytest +import pytest_asyncio + -@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 @@ -25,7 +29,7 @@ def event_loop(): loop.close() -@pytest.fixture(scope="module") +@pytest_asyncio.fixture(loop_scope="module", scope="module") async def port_with_event_loop_finalizer(request): def port_finalizer(finalizer): async def port_afinalizer(): @@ -40,7 +44,7 @@ async def port_afinalizer(): return True -@pytest.fixture(scope="module") +@pytest_asyncio.fixture(loop_scope="module", scope="module") async def port_with_get_event_loop_finalizer(request): def port_finalizer(finalizer): async def port_afinalizer(): diff --git a/tests/async_fixtures/test_async_gen_fixtures.py b/tests/async_fixtures/test_async_gen_fixtures.py index 2b198f2b..ddc2f5be 100644 --- a/tests/async_fixtures/test_async_gen_fixtures.py +++ b/tests/async_fixtures/test_async_gen_fixtures.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest.mock import pytest diff --git a/tests/async_fixtures/test_nested.py b/tests/async_fixtures/test_nested.py index da7ee3a1..72b5129a 100644 --- a/tests/async_fixtures/test_nested.py +++ b/tests/async_fixtures/test_nested.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import pytest diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py index 2bdbe5e8..ca2cb5c7 100644 --- a/tests/async_fixtures/test_parametrized_loop.py +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester 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..3295c83a --- /dev/null +++ b/tests/async_fixtures/test_shared_module_fixture.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest_asyncio + @pytest_asyncio.fixture(loop_scope="module", 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/conftest.py b/tests/conftest.py index 4aa8c89a..76e2026f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import pytest diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index 2d2171bd..4b185f62 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -1,7 +1,10 @@ -"""Tests for the Hypothesis integration, which wraps async functions in a +""" +Tests for the Hypothesis integration, which wraps async functions in a sync shim for Hypothesis. """ +from __future__ import annotations + from textwrap import dedent import pytest @@ -10,6 +13,7 @@ def test_hypothesis_given_decorator_before_asyncio_mark(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -42,6 +46,7 @@ async def test_mark_and_parametrize(x, y): def test_can_use_explicit_event_loop_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module") pytester.makepyfile( dedent( """\ @@ -78,6 +83,7 @@ async def test_explicit_fixture_request(event_loop, n): def test_async_auto_marked(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -100,6 +106,7 @@ async def test_hypothesis(n: int): def test_sync_not_auto_marked(pytester: Pytester): """Assert that synchronous Hypothesis functions are not marked with asyncio""" + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ diff --git a/tests/loop_fixture_scope/conftest.py b/tests/loop_fixture_scope/conftest.py index 6b9a7649..4e8b06de 100644 --- a/tests/loop_fixture_scope/conftest.py +++ b/tests/loop_fixture_scope/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import pytest diff --git a/tests/loop_fixture_scope/test_loop_fixture_scope.py b/tests/loop_fixture_scope/test_loop_fixture_scope.py index eb4be8c9..eb1bae58 100644 --- a/tests/loop_fixture_scope/test_loop_fixture_scope.py +++ b/tests/loop_fixture_scope/test_loop_fixture_scope.py @@ -1,5 +1,7 @@ """Unit tests for overriding the event loop with a larger scoped one.""" +from __future__ import annotations + import asyncio import pytest diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 3c77bab0..4bddb4b8 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -1,5 +1,7 @@ """Test if pytestmark works when defined on a class.""" +from __future__ import annotations + import asyncio from textwrap import dedent @@ -30,6 +32,7 @@ def sample_fixture(): def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_functions( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -39,11 +42,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 """ @@ -56,13 +59,14 @@ async def test_this_runs_in_same_loop(self): def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_class( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -81,13 +85,14 @@ async def test_this_runs_in_same_loop(self): def test_asyncio_mark_raises_when_class_scoped_is_request_without_class( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") async def test_has_no_surrounding_class(): pass """ @@ -101,13 +106,14 @@ async def test_has_no_surrounding_class(): def test_asyncio_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ import asyncio import pytest - @pytest.mark.asyncio(scope="class") + @pytest.mark.asyncio(loop_scope="class") class TestSuperClassWithMark: pass @@ -129,6 +135,7 @@ async def test_this_runs_in_same_loop(self): def test_asyncio_mark_respects_the_loop_policy( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -166,6 +173,7 @@ async def test_does_not_use_custom_event_loop_policy(): def test_asyncio_mark_respects_parametrized_loop_policies( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -183,7 +191,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 @@ -197,6 +205,7 @@ async def test_parametrized_loop(self, request): def test_asyncio_mark_provides_class_scoped_loop_to_fixtures( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -205,7 +214,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 @@ -226,6 +235,7 @@ async def test_runs_is_same_loop_as_fixture(self, my_fixture): def test_asyncio_mark_allows_combining_class_scoped_fixture_with_function_scoped_test( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -237,12 +247,12 @@ def test_asyncio_mark_allows_combining_class_scoped_fixture_with_function_scoped loop: asyncio.AbstractEventLoop class TestMixedScopes: - @pytest_asyncio.fixture(scope="class") + @pytest_asyncio.fixture(loop_scope="class", scope="class") 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 @@ -257,6 +267,7 @@ async def test_runs_in_different_loop_as_fixture(self, async_fixture): def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -277,7 +288,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): @@ -292,12 +303,13 @@ async def test_does_not_fail(self, sets_event_loop_to_none, n): def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ 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..c17a6225 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_asyncio_mark_provides_function_scoped_loop_strict_mode(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -28,9 +31,71 @@ 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.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + 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.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + 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.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + 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, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -53,6 +118,7 @@ async def test_remember_loop(event_loop): def test_asyncio_mark_respects_the_loop_policy( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -83,6 +149,7 @@ async def test_uses_custom_event_loop_policy(): def test_asyncio_mark_respects_parametrized_loop_policies( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -120,6 +187,7 @@ async def test_parametrized_loop(): def test_asyncio_mark_provides_function_scoped_loop_to_fixtures( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -150,6 +218,7 @@ async def test_runs_is_same_loop_as_fixture(my_fixture): def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -184,6 +253,7 @@ async def test_does_not_fail(sets_event_loop_to_none, n): def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -202,6 +272,7 @@ async def test_anything(): def test_asyncio_mark_does_not_duplicate_other_marks_in_auto_mode( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makeconftest( dedent( """\ diff --git a/tests/markers/test_invalid_arguments.py b/tests/markers/test_invalid_arguments.py new file mode 100644 index 00000000..2d5c3552 --- /dev/null +++ b/tests/markers/test_invalid_arguments.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +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 'loop_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_mixed_scope.py b/tests/markers/test_mixed_scope.py new file mode 100644 index 00000000..40eaaa35 --- /dev/null +++ b/tests/markers/test_mixed_scope.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_function_scoped_loop_restores_previous_loop_scope(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + + module_loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio(loop_scope="module") + async def test_remember_loop(): + global module_loop + module_loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="function") + async def test_with_function_scoped_loop(): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_runs_in_same_loop(): + global module_loop + assert asyncio.get_running_loop() is module_loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 5cc6a2a7..7dbdbb7f 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_asyncio_mark_works_on_module_level(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -56,13 +59,14 @@ def sample_fixture(): def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ import asyncio import pytest - pytestmark = pytest.mark.asyncio(scope="module") + pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop @@ -88,13 +92,14 @@ async def test_this_runs_in_same_loop(self): def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ 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 @@ -109,6 +114,7 @@ async def test_remember_loop(event_loop): def test_asyncio_mark_respects_the_loop_policy( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", custom_policy=dedent( @@ -126,7 +132,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 +152,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( @@ -163,6 +169,7 @@ async def test_does_not_use_custom_event_loop_policy(): def test_asyncio_mark_respects_parametrized_loop_policies( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -170,7 +177,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", @@ -194,6 +201,7 @@ async def test_parametrized_loop(): def test_asyncio_mark_provides_module_scoped_loop_to_fixtures( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -202,11 +210,11 @@ 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 - @pytest_asyncio.fixture(scope="module") + @pytest_asyncio.fixture(loop_scope="module", scope="module") async def my_fixture(): global loop loop = asyncio.get_running_loop() @@ -224,6 +232,7 @@ async def test_runs_is_same_loop_as_fixture(my_fixture): def test_asyncio_mark_allows_combining_module_scoped_fixture_with_class_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -234,12 +243,12 @@ def test_asyncio_mark_allows_combining_module_scoped_fixture_with_class_scoped_t loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="module") + @pytest_asyncio.fixture(loop_scope="module", scope="module") 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 @@ -255,6 +264,7 @@ async def test_runs_in_different_loop_as_fixture(self, async_fixture): def test_asyncio_mark_allows_combining_module_scoped_fixture_with_function_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -266,12 +276,12 @@ def test_asyncio_mark_allows_combining_module_scoped_fixture_with_function_scope loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="module") + @pytest_asyncio.fixture(loop_scope="module", scope="module") 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 @@ -285,6 +295,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_allows_combining_module_scoped_asyncgen_fixture_with_function_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -295,13 +306,13 @@ def test_allows_combining_module_scoped_asyncgen_fixture_with_function_scoped_te loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="module") + @pytest_asyncio.fixture(loop_scope="module", scope="module") async def async_fixture(): global loop 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 @@ -315,6 +326,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -334,7 +346,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): @@ -349,12 +361,13 @@ async def test_does_not_fail(sets_event_loop_to_none, n): def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ 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..204238a4 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester @@ -6,6 +8,7 @@ def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester): package_name = pytester.path.name subpackage_name = "subpkg" + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", shared_module=dedent( @@ -22,7 +25,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 +37,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 +58,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 @@ -69,6 +72,7 @@ async def test_subpackage_runs_in_different_loop(): def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_raises=dedent( @@ -76,7 +80,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 """ @@ -90,6 +94,7 @@ async def test_remember_loop(event_loop): def test_asyncio_mark_respects_the_loop_policy( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", conftest=dedent( @@ -118,7 +123,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 +139,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( @@ -151,6 +156,7 @@ async def test_also_uses_custom_event_loop_policy(): def test_asyncio_mark_respects_parametrized_loop_policies( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_parametrization=dedent( @@ -159,7 +165,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", @@ -183,6 +189,7 @@ async def test_parametrized_loop(): def test_asyncio_mark_provides_package_scoped_loop_to_fixtures( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") package_name = pytester.path.name pytester.makepyfile( __init__="", @@ -194,7 +201,7 @@ def test_asyncio_mark_provides_package_scoped_loop_to_fixtures( from {package_name} import shared_module - @pytest_asyncio.fixture(scope="package") + @pytest_asyncio.fixture(loop_scope="package", scope="package") async def my_fixture(): shared_module.loop = asyncio.get_running_loop() """ @@ -215,7 +222,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 @@ -229,6 +236,7 @@ async def test_runs_in_same_loop_as_fixture(my_fixture): def test_asyncio_mark_allows_combining_package_scoped_fixture_with_module_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -240,12 +248,12 @@ def test_asyncio_mark_allows_combining_package_scoped_fixture_with_module_scoped loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="package") + @pytest_asyncio.fixture(loop_scope="package", scope="package") 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 @@ -259,6 +267,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_allows_combining_package_scoped_fixture_with_class_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -270,12 +279,12 @@ def test_asyncio_mark_allows_combining_package_scoped_fixture_with_class_scoped_ loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="package") + @pytest_asyncio.fixture(loop_scope="package", scope="package") 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 @@ -290,6 +299,7 @@ async def test_runs_in_different_loop_as_fixture(self, async_fixture): def test_asyncio_mark_allows_combining_package_scoped_fixture_with_function_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -301,7 +311,7 @@ def test_asyncio_mark_allows_combining_package_scoped_fixture_with_function_scop loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="package") + @pytest_asyncio.fixture(loop_scope="package", scope="package") async def async_fixture(): global loop loop = asyncio.get_running_loop() @@ -320,6 +330,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_loop_is_none=dedent( @@ -340,7 +351,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): @@ -355,13 +366,14 @@ async def test_does_not_fail(sets_event_loop_to_none, n): def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_module=dedent( """\ 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..70e191b2 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester @@ -5,6 +7,7 @@ def test_asyncio_mark_provides_session_scoped_loop_strict_mode(pytester: Pytester): package_name = pytester.path.name + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", shared_module=dedent( @@ -21,7 +24,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 +36,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 +59,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 @@ -70,6 +73,7 @@ async def test_subpackage_runs_in_same_loop(): def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_raises=dedent( @@ -77,7 +81,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 """ @@ -91,6 +95,7 @@ async def test_remember_loop(event_loop): def test_asyncio_mark_respects_the_loop_policy( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", conftest=dedent( @@ -119,7 +124,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 +140,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( @@ -152,6 +157,7 @@ async def test_also_uses_custom_event_loop_policy(): def test_asyncio_mark_respects_parametrized_loop_policies( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_parametrization=dedent( @@ -160,7 +166,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", @@ -185,6 +191,7 @@ def test_asyncio_mark_provides_session_scoped_loop_to_fixtures( pytester: Pytester, ): package_name = pytester.path.name + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", conftest=dedent( @@ -195,7 +202,7 @@ def test_asyncio_mark_provides_session_scoped_loop_to_fixtures( from {package_name} import shared_module - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") async def my_fixture(): shared_module.loop = asyncio.get_running_loop() """ @@ -220,7 +227,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 @@ -234,6 +241,7 @@ async def test_runs_in_same_loop_as_fixture(my_fixture): def test_asyncio_mark_allows_combining_session_scoped_fixture_with_package_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -245,12 +253,12 @@ def test_asyncio_mark_allows_combining_session_scoped_fixture_with_package_scope loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") 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 @@ -264,6 +272,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_allows_combining_session_scoped_fixture_with_module_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -275,12 +284,12 @@ def test_asyncio_mark_allows_combining_session_scoped_fixture_with_module_scoped loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") 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 @@ -294,6 +303,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_allows_combining_session_scoped_fixture_with_class_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -305,12 +315,12 @@ def test_asyncio_mark_allows_combining_session_scoped_fixture_with_class_scoped_ loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") 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 @@ -325,6 +335,7 @@ async def test_runs_in_different_loop_as_fixture(self, async_fixture): def test_asyncio_mark_allows_combining_session_scoped_fixture_with_function_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -336,7 +347,7 @@ def test_asyncio_mark_allows_combining_session_scoped_fixture_with_function_scop loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") async def async_fixture(): global loop loop = asyncio.get_running_loop() @@ -355,6 +366,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_allows_combining_session_scoped_asyncgen_fixture_with_function_scoped_test( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_mixed_scopes=dedent( @@ -366,7 +378,7 @@ def test_allows_combining_session_scoped_asyncgen_fixture_with_function_scoped_t loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture(scope="session") + @pytest_asyncio.fixture(loop_scope="session", scope="session") async def async_fixture(): global loop loop = asyncio.get_running_loop() @@ -386,6 +398,7 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -405,7 +418,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): @@ -420,12 +433,13 @@ async def test_does_not_fail(sets_event_loop_to_none, n): def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ import pytest - @pytest.mark.asyncio(scope="session") + @pytest.mark.asyncio(loop_scope="session") async def test_anything(): pass """ diff --git a/tests/modes/test_auto_mode.py b/tests/modes/test_auto_mode.py index fc4d2df0..21c48d87 100644 --- a/tests/modes/test_auto_mode.py +++ b/tests/modes/test_auto_mode.py @@ -1,8 +1,13 @@ +from __future__ import annotations + from textwrap import dedent +from pytest import Pytester + -def test_auto_mode_cmdline(testdir): - testdir.makepyfile( +def test_auto_mode_cmdline(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -15,12 +20,21 @@ async def test_a(): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_auto_mode_cfg(testdir): - testdir.makepyfile( +def test_auto_mode_cfg(pytester: Pytester): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_mode = auto + """ + ) + ) + pytester.makepyfile( dedent( """\ import asyncio @@ -33,13 +47,13 @@ async def test_a(): """ ) ) - testdir.makefile(".ini", pytest="[pytest]\nasyncio_mode = auto\n") - result = testdir.runpytest() + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_auto_mode_async_fixture(testdir): - testdir.makepyfile( +def test_auto_mode_async_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -58,12 +72,13 @@ async def test_a(fixture_a): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_auto_mode_method_fixture(testdir): - testdir.makepyfile( +def test_auto_mode_method_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -85,12 +100,13 @@ async def test_a(self, fixture_a): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_auto_mode_static_method(testdir): - testdir.makepyfile( +def test_auto_mode_static_method(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -106,12 +122,13 @@ async def test_a(): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_auto_mode_static_method_fixture(testdir): - testdir.makepyfile( +def test_auto_mode_static_method_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -135,5 +152,5 @@ async def test_a(fixture_a): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) diff --git a/tests/modes/test_strict_mode.py b/tests/modes/test_strict_mode.py index 220410be..52cbb251 100644 --- a/tests/modes/test_strict_mode.py +++ b/tests/modes/test_strict_mode.py @@ -1,8 +1,13 @@ +from __future__ import annotations + from textwrap import dedent +from pytest import Pytester + -def test_strict_mode_cmdline(testdir): - testdir.makepyfile( +def test_strict_mode_cmdline(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -16,12 +21,21 @@ async def test_a(): """ ) ) - result = testdir.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1) -def test_strict_mode_cfg(testdir): - testdir.makepyfile( +def test_strict_mode_cfg(pytester: Pytester): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_mode = strict + """ + ) + ) + pytester.makepyfile( dedent( """\ import asyncio @@ -35,13 +49,13 @@ async def test_a(): """ ) ) - testdir.makefile(".ini", pytest="[pytest]\nasyncio_mode = strict\n") - result = testdir.runpytest() + result = pytester.runpytest() result.assert_outcomes(passed=1) -def test_strict_mode_method_fixture(testdir): - testdir.makepyfile( +def test_strict_mode_method_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import asyncio @@ -64,12 +78,13 @@ async def test_a(self, fixture_a): """ ) ) - result = testdir.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) -def test_strict_mode_ignores_unmarked_coroutine(testdir): - testdir.makepyfile( +def test_strict_mode_ignores_unmarked_coroutine(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import pytest @@ -79,13 +94,14 @@ async def test_anything(): """ ) ) - result = testdir.runpytest_subprocess("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(skipped=1, warnings=1) result.stdout.fnmatch_lines(["*async def functions are not natively supported*"]) -def test_strict_mode_ignores_unmarked_fixture(testdir): - testdir.makepyfile( +def test_strict_mode_ignores_unmarked_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import pytest @@ -100,7 +116,7 @@ async def test_anything(any_fixture): """ ) ) - result = testdir.runpytest_subprocess("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(skipped=1, warnings=2) result.stdout.fnmatch_lines( [ @@ -108,3 +124,90 @@ async def test_anything(any_fixture): "*coroutine 'any_fixture' was never awaited*", ], ) + + +def test_strict_mode_marked_test_unmarked_fixture_warning(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + # Not using pytest_asyncio.fixture + @pytest.fixture() + async def any_fixture(): + pass + + @pytest.mark.asyncio + async def test_anything(any_fixture): + # suppress unawaited coroutine warning + try: + any_fixture.send(None) + except StopIteration: + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1, failed=0, skipped=0, warnings=1) + result.stdout.fnmatch_lines( + [ + "*warnings summary*", + ( + "test_strict_mode_marked_test_unmarked_fixture_warning.py::" + "test_anything" + ), + ( + "*/pytest_asyncio/plugin.py:*: PytestDeprecationWarning: " + "asyncio test 'test_anything' requested async " + "@pytest.fixture 'any_fixture' in strict mode. " + "You might want to use @pytest_asyncio.fixture or switch to " + "auto mode. " + "This will become an error in future versions of flake8-asyncio." + ), + ], + ) + + +# autouse is not handled in any special way currently +def test_strict_mode_marked_test_unmarked_autouse_fixture_warning(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + # Not using pytest_asyncio.fixture + @pytest.fixture(autouse=True) + async def any_fixture(): + pass + + @pytest.mark.asyncio + async def test_anything(any_fixture): + # suppress unawaited coroutine warning + try: + any_fixture.send(None) + except StopIteration: + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines( + [ + "*warnings summary*", + ( + "test_strict_mode_marked_test_unmarked_autouse_fixture_warning.py::" + "test_anything" + ), + ( + "*/pytest_asyncio/plugin.py:*: PytestDeprecationWarning: " + "*asyncio test 'test_anything' requested async " + "@pytest.fixture 'any_fixture' in strict mode. " + "You might want to use @pytest_asyncio.fixture or switch to " + "auto mode. " + "This will become an error in future versions of flake8-asyncio." + ), + ], + ) diff --git a/tests/test_asyncio_fixture.py b/tests/test_asyncio_fixture.py index 2577cba0..91e5d8d4 100644 --- a/tests/test_asyncio_fixture.py +++ b/tests/test_asyncio_fixture.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import asyncio from textwrap import dedent import pytest +from pytest import Pytester import pytest_asyncio @@ -43,8 +46,9 @@ async def test_fixture_with_params(fixture_with_params): @pytest.mark.parametrize("mode", ("auto", "strict")) -def test_sync_function_uses_async_fixture(testdir, mode): - testdir.makepyfile( +def test_sync_function_uses_async_fixture(pytester: Pytester, mode): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( dedent( """\ import pytest_asyncio @@ -60,5 +64,5 @@ def test_sync_function_uses_async_fixture(always_true): """ ) ) - result = testdir.runpytest(f"--asyncio-mode={mode}") + result = pytester.runpytest(f"--asyncio-mode={mode}") result.assert_outcomes(passed=1) diff --git a/tests/test_asyncio_mark.py b/tests/test_asyncio_mark.py index 20ac173d..81731adb 100644 --- a/tests/test_asyncio_mark.py +++ b/tests/test_asyncio_mark.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester @@ -144,3 +146,80 @@ async def test_a(): result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] ) + + +def test_asyncio_marker_fallbacks_to_configured_default_loop_scope_if_not_set( + pytester: Pytester, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_default_test_loop_scope = session + """ + ) + ) + + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest_asyncio + import pytest + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def session_loop_fixture(): + global loop + loop = asyncio.get_running_loop() + + async def test_a(session_loop_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_asyncio_marker_uses_marker_loop_scope_even_if_config_is_set( + pytester: Pytester, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_default_test_loop_scope = module + """ + ) + ) + + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest_asyncio + import pytest + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def session_loop_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="session") + async def test_a(session_loop_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/test_dependent_fixtures.py b/tests/test_dependent_fixtures.py index dc70fe9c..2e53700a 100644 --- a/tests/test_dependent_fixtures.py +++ b/tests/test_dependent_fixtures.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import pytest diff --git a/tests/test_doctest.py b/tests/test_doctest.py index 5b79619a..d175789e 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_plugin_does_not_interfere_with_doctest_collection(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( '''\ @@ -20,6 +23,7 @@ def any_function(): def test_plugin_does_not_interfere_with_doctest_textfile_collection(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makefile(".txt", "") # collected as DoctestTextfile pytester.makepyfile( __init__="", diff --git a/tests/test_event_loop_fixture.py b/tests/test_event_loop_fixture.py index aaf591c9..5417a14d 100644 --- a/tests/test_event_loop_fixture.py +++ b/tests/test_event_loop_fixture.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester @@ -51,3 +53,88 @@ async def test_custom_policy_is_not_overwritten(): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_event_loop_fixture_handles_unclosed_async_gen( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_something(): + async def generator_fn(): + yield + yield + + gen = generator_fn() + await gen.__anext__() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=0) + + +def test_event_loop_already_closed( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + pytest_plugins = 'pytest_asyncio' + + @pytest_asyncio.fixture + async def _event_loop(): + return asyncio.get_running_loop() + + @pytest.fixture + def cleanup_after(_event_loop): + yield + # fixture has its own cleanup code + _event_loop.close() + + @pytest.mark.asyncio + async def test_something(cleanup_after): + await asyncio.sleep(0.01) + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=0) + + +def test_event_loop_fixture_asyncgen_error( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_something(): + # mock shutdown_asyncgen failure + loop = asyncio.get_running_loop() + async def fail(): + raise RuntimeError("mock error cleaning up...") + loop.shutdown_asyncgens = fail + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=1) diff --git a/tests/test_event_loop_fixture_finalizer.py b/tests/test_event_loop_fixture_finalizer.py index ae260261..1e378643 100644 --- a/tests/test_event_loop_fixture_finalizer.py +++ b/tests/test_event_loop_fixture_finalizer.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -11,7 +14,8 @@ def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Py import pytest - loop = asyncio.get_event_loop_policy().get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) @pytest.mark.asyncio async def test_1(): @@ -36,6 +40,7 @@ def test_2(): def test_event_loop_fixture_finalizer_handles_loop_set_to_none_sync( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -53,6 +58,7 @@ def test_sync(event_loop): def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_without_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -72,6 +78,7 @@ async def test_async_without_explicit_fixture_request(): def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_with_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -94,6 +101,7 @@ async def test_async_with_explicit_fixture_request(event_loop): def test_event_loop_fixture_finalizer_raises_warning_when_fixture_leaves_loop_unclosed( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -121,6 +129,7 @@ async def test_ends_with_unclosed_loop(): def test_event_loop_fixture_finalizer_raises_warning_when_test_leaves_loop_unclosed( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ diff --git a/tests/test_event_loop_fixture_override_deprecation.py b/tests/test_event_loop_fixture_override_deprecation.py index 683f0963..04859ef7 100644 --- a/tests/test_event_loop_fixture_override_deprecation.py +++ b/tests/test_event_loop_fixture_override_deprecation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester diff --git a/tests/test_explicit_event_loop_fixture_request.py b/tests/test_explicit_event_loop_fixture_request.py index e09893fa..c685ad84 100644 --- a/tests/test_explicit_event_loop_fixture_request.py +++ b/tests/test_explicit_event_loop_fixture_request.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester @@ -6,6 +8,7 @@ def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -27,6 +30,7 @@ async def test_coroutine_emits_warning(event_loop): def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_method( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -49,6 +53,7 @@ async def test_coroutine_emits_warning(self, event_loop): def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_staticmethod( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -72,6 +77,7 @@ async def test_coroutine_emits_warning(event_loop): def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -98,6 +104,7 @@ async def test_uses_fixture(emits_warning): def test_emit_warning_when_event_loop_is_explicitly_requested_in_async_gen_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -124,6 +131,7 @@ async def test_uses_fixture(emits_warning): def test_does_not_emit_warning_when_event_loop_is_explicitly_requested_in_sync_function( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -141,6 +149,7 @@ def test_uses_fixture(event_loop): def test_does_not_emit_warning_when_event_loop_is_explicitly_requested_in_sync_fixture( pytester: Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ diff --git a/tests/test_fixture_loop_scopes.py b/tests/test_fixture_loop_scopes.py new file mode 100644 index 00000000..a9ce4b35 --- /dev/null +++ b/tests/test_fixture_loop_scopes.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +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_import.py b/tests/test_import.py index 9912ae0c..2272704a 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_import_warning_does_not_cause_internal_error(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -19,6 +22,7 @@ async def test_errors_out(): def test_import_warning_in_package_does_not_cause_internal_error(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__=dedent( """\ @@ -37,6 +41,7 @@ async def test_errors_out(): def test_does_not_import_unrelated_packages(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pkg_dir = pytester.mkpydir("mypkg") pkg_dir.joinpath("__init__.py").write_text( dedent( diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py index 12e791c1..f99dc0d9 100644 --- a/tests/test_is_async_test.py +++ b/tests/test_is_async_test.py @@ -1,10 +1,12 @@ +from __future__ import annotations + from textwrap import dedent -import pytest from pytest import Pytester def test_returns_false_for_sync_item(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -29,6 +31,7 @@ def pytest_collection_modifyitems(items): def test_returns_true_for_marked_coroutine_item_in_strict_mode(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -54,6 +57,7 @@ def pytest_collection_modifyitems(items): def test_returns_false_for_unmarked_coroutine_item_in_strict_mode(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -74,16 +78,11 @@ 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): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ diff --git a/tests/test_multiloop.py b/tests/test_multiloop.py index c3713cc9..e6c852b9 100644 --- a/tests/test_multiloop.py +++ b/tests/test_multiloop.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester diff --git a/tests/test_port_factories.py b/tests/test_port_factories.py index cbbd47b4..713d747e 100644 --- a/tests/test_port_factories.py +++ b/tests/test_port_factories.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester diff --git a/tests/test_simple.py b/tests/test_simple.py index f5f52a8d..b8a34fb2 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,5 +1,7 @@ """Quick'n'dirty unit tests for provided fixtures and markers.""" +from __future__ import annotations + import asyncio from textwrap import dedent @@ -26,6 +28,7 @@ async def test_asyncio_marker(): def test_asyncio_marker_compatibility_with_xfail(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -45,6 +48,7 @@ async def test_asyncio_marker_fail(): def test_asyncio_auto_mode_compatibility_with_xfail(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -73,8 +77,10 @@ class TestMarkerInClassBasedTests: @pytest.mark.asyncio async def test_asyncio_marker_with_implicit_loop_fixture(self): - """Test the "asyncio" marker works on a method in - a class-based test with implicit loop fixture.""" + """ + Test the "asyncio" marker works on a method in + a class-based test with implicit loop fixture. + """ ret = await async_coro() assert ret == "ok" @@ -101,8 +107,9 @@ async def test_event_loop_before_fixture(self, loop): assert await loop.run_in_executor(None, self.foo) == 1 -def test_invalid_asyncio_mode(testdir): - result = testdir.runpytest("-o", "asyncio_mode=True") +def test_invalid_asyncio_mode(pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + result = pytester.runpytest("-o", "asyncio_mode=True") result.stderr.no_fnmatch_line("INTERNALERROR> *") result.stderr.fnmatch_lines( "ERROR: 'True' is not a valid asyncio_mode. Valid modes: auto, strict." diff --git a/tests/test_skips.py b/tests/test_skips.py index 5d7aa303..d32273cd 100644 --- a/tests/test_skips.py +++ b/tests/test_skips.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from textwrap import dedent from pytest import Pytester def test_asyncio_strict_mode_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -22,6 +25,7 @@ async def test_no_warning_on_skip(): def test_asyncio_auto_mode_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -39,6 +43,7 @@ async def test_no_warning_on_skip(): def test_asyncio_strict_mode_module_level_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -57,6 +62,7 @@ async def test_is_skipped(): def test_asyncio_auto_mode_module_level_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -74,6 +80,7 @@ async def test_is_skipped(): def test_asyncio_auto_mode_wrong_skip_usage(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -91,6 +98,7 @@ async def test_is_skipped(): def test_unittest_skiptest_compatibility(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -108,6 +116,7 @@ async def test_is_skipped(): def test_skip_in_module_does_not_skip_package(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( __init__="", test_skip=dedent( diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index 3d91e7b1..c32ba964 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -1,5 +1,7 @@ """Tests for using subprocesses in tests.""" +from __future__ import annotations + import asyncio.subprocess import sys diff --git a/tools/get-version.py b/tools/get-version.py index c29081b9..9d24b6a5 100644 --- a/tools/get-version.py +++ b/tools/get-version.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import sys from importlib import metadata diff --git a/tox.ini b/tox.ini index 665c2fff..9a0cf93b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -minversion = 3.14.0 -envlist = py38, py39, py310, py311, py312, py13, pytest-min, docs +minversion = 4.9.0 +envlist = py39, py310, py311, py312, py313, pytest-min, docs isolated_build = true passenv = CI @@ -26,19 +26,52 @@ 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 = - 3.8: py38, pytest-min - 3.9: py39 + 3.9: py39, pytest-min 3.10: py310 3.11: py311 3.12: py312