diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9a663d8e..c79c0de8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,7 @@ version: 2 updates: - package-ecosystem: pip - directory: /dependencies/default + directory: / schedule: interval: weekly open-pull-requests-limit: 10 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 17800cff..08fc7ebb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,72 +10,89 @@ on: merge_group: workflow_dispatch: +permissions: {} + env: - PYTHON_LATEST: 3.12 + PYTHON_LATEST: 3.13 jobs: - lint: - name: Run linters + build: + name: Build package runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} prerelease: ${{ steps.version.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + persist-credentials: false + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_LATEST }} - - name: Install GitHub matcher for ActionLint checker - run: | - echo "::add-matcher::.github/actionlint-matcher.json" - - name: Install pre-commit - run: python -m pip install pre-commit - - 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 - - name: Build package - run: python -m build + - name: Install tox + run: python -m pip install tox + - name: Build package and check distributions + run: tox run -e build - name: List result run: ls -l dist - - name: Check wheel contents - run: check-wheel-contents dist/*.whl - - name: Check long_description - run: python -m twine check dist/* - name: Install pytest-asyncio run: pip install . - name: Get version info id: version run: python ./tools/get-version.py >> $GITHUB_OUTPUT - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist - test: - name: Python ${{ matrix.python-version }} + lint: + name: Run linters runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_LATEST }} + - name: Install GitHub matcher for ActionLint checker + run: | + echo "::add-matcher::.github/actionlint-matcher.json" + - name: Install pre-commit + run: python -m pip install pre-commit + - name: Run pre-commit checks + run: pre-commit run --all-files --show-diff-on-failure + test: + name: ${{ matrix.os }} - Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }}-latest + continue-on-error: ${{ !matrix.required }} strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + os: [ubuntu, windows] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + required: [true] + include: + - os: ubuntu + python-version: 3.14-dev + required: false + - os: windows + python-version: 3.14-dev + required: false + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - if: "!endsWith(matrix.python-version, '-dev')" + - uses: actions/checkout@v5 with: - python-version: ${{ matrix.python-version }} - - uses: deadsnakes/action@v3.0.1 - if: endsWith(matrix.python-version, '-dev') + persist-credentials: false + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - set -xe python -VV python -m site python -m pip install --upgrade pip @@ -84,23 +101,33 @@ jobs: run: python -m tox - name: Store coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 + if: "!endsWith(matrix.os, 'windows')" with: - name: coverage-per-interpreter - path: .coverage.* + name: coverage-python-${{ matrix.python-version }} + path: coverage/coverage.* + if-no-files-found: error + + lint-github-actions: + name: Lint GitHub Actions + permissions: + security-events: write + uses: zizmorcore/workflow/.github/workflows/reusable-zizmor.yml@3bb5e95068d0f44b6d2f3f7e91379bed1d2f96a8 check: name: Check if: always() - needs: [lint, test] + needs: [build, lint, test] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 + uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_LATEST }} - name: Install Coverage.py @@ -108,53 +135,117 @@ jobs: set -xe python -m pip install --upgrade coverage[toml] - name: Download coverage data for all test runs - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v5 with: - name: coverage-per-interpreter + 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@v3 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: files: coverage.xml fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} - deploy: - name: Deploy - environment: release - # Run only on pushing a tag - if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') - needs: [lint, check] + create-github-release: + name: Create GitHub release + needs: [build, lint, check] runs-on: ubuntu-latest + permissions: + contents: write steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install Python + uses: actions/setup-python@v6 + - name: Install towncrier + run: pip install towncrier==24.8.0 - name: Install pandoc run: | sudo apt-get install -y pandoc - - name: Checkout - uses: actions/checkout@v4 + - name: Install pytest-asyncio + run: pip install . + - name: Compile Release Notes Draft + if: ${{ !contains(github.ref, 'refs/tags/') }} + run: towncrier build --draft --version "${version}" > release-notes.rst + env: + version: ${{ needs.build.outputs.version }} + - name: Extract release notes from Git tag + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + run: | + set -e + git fetch --tags --force # see https://github.com/actions/checkout/issues/290 + git for-each-ref "${GITHUB_REF}" --format='%(contents)' > release-notes.rst + # Strip signature from signed tags + sed -i -e "/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----\n/d" \ + -e "/-----BEGIN SSH SIGNATURE-----/,/-----END SSH SIGNATURE-----\n/d" release-notes.rst + - name: Convert Release Notes to Markdown + run: | + pandoc --wrap=preserve -o release-notes.md release-notes.rst + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: release-notes.md + path: release-notes.md + - name: Download distributions + uses: actions/download-artifact@v5 + with: + name: dist + path: dist + - name: Create GitHub Release + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + with: + name: pytest-asyncio ${{ needs.build.outputs.version }} + artifacts: dist/* + bodyFile: release-notes.md + prerelease: ${{ needs.build.outputs.prerelease }} + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + draft: true + skipIfReleaseExists: true + + publish-test-pypi: + name: Publish packages to test.pypi.org + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [build, lint, check, create-github-release] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: - name: Download distributions - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v5 + with: + name: dist + path: dist + - name: Upload to test.pypi.org + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish packages to pypi.org + environment: release + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + needs: [build, lint, check, create-github-release] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v5 with: name: dist path: dist - name: Collected dists run: | tree dist - - name: Convert README.rst to Markdown - run: | - pandoc -s -o README.md README.rst - name: PyPI upload - uses: pypa/gh-action-pypi-publish@v1.8.10 - with: - packages_dir: dist - password: ${{ secrets.PYPI_API_TOKEN }} - - name: GitHub Release - uses: ncipollo/release-action@v1 - with: - name: pytest-asyncio ${{ needs.lint.outputs.version }} - artifacts: dist/* - bodyFile: README.md - prerelease: ${{ needs.lint.outputs.prerelease }} - token: ${{ secrets.GITHUB_TOKEN }} + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.gitignore b/.gitignore index 7dd9b771..076a7d00 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 @@ -63,7 +62,3 @@ target/ # pyenv .python-version - - -# generated by setuptools_scm -pytest_asyncio/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e5d2f8e..27789271 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,32 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v6.0.0 hooks: + - id: trailing-whitespace + - id: end-of-file-fixer - id: check-merge-conflict exclude: rst$ + - id: check-case-conflict + - id: check-json + - id: check-xml + - id: check-yaml + - id: debug-statements +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.2 + hooks: + - id: ruff + args: [--fix] +- repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade - 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: 0.10.7 + rev: 2025.6.1 hooks: - id: shed args: @@ -20,38 +36,23 @@ 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 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: fix-encoding-pragma - args: [--remove] - - id: check-case-conflict - - id: check-json - - id: check-xml - - id: check-yaml - - id: debug-statements - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.18.2 hooks: - id: mypy exclude: ^(docs|tests)/.* -- repo: https://github.com/pycqa/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - language_version: python3 + additional_dependencies: + - pytest - 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.6.22 + rev: v1.7.7 hooks: - id: actionlint-docker args: @@ -61,10 +62,21 @@ repos: - 'SC2086:' - -ignore - 'SC1004:' + stages: [manual] - repo: https://github.com/sirosen/check-jsonschema - rev: 0.19.2 + rev: 0.34.0 hooks: - id: check-github-actions +- repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.6.0 + hooks: + - id: pyproject-fmt + # https://pyproject-fmt.readthedocs.io/en/latest/#calculating-max-supported-python-version + additional_dependencies: [tox>=4.28] +- repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.14.2 + hooks: + - id: zizmor ci: skip: - actionlint-docker diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..8ffb4b25 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,29 @@ +--- + +version: 2 + +build: + os: ubuntu-24.04 + tools: + 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/README.rst b/README.rst index 0682b744..e056a880 100644 --- a/README.rst +++ b/README.rst @@ -10,8 +10,9 @@ pytest-asyncio .. image:: https://img.shields.io/pypi/pyversions/pytest-asyncio.svg :target: https://github.com/pytest-dev/pytest-asyncio :alt: Supported Python versions -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/ambv/black +.. image:: https://img.shields.io/badge/Matrix-%23pytest--asyncio-brightgreen + :alt: Matrix chat room: #pytest-asyncio + :target: https://matrix.to/#/#pytest-asyncio:matrix.org `pytest-asyncio `_ is a `pytest `_ plugin. It facilitates testing of code that uses the `asyncio `_ library. diff --git a/SECURITY.rst b/SECURITY.rst new file mode 100644 index 00000000..cebe85ad --- /dev/null +++ b/SECURITY.rst @@ -0,0 +1,4 @@ +Security contact information +============================ + +To report a security vulnerability, please use the `Tidelift security contact. `__ Tidelift will coordinate the fix and disclosure. diff --git a/changelog.d/.gitkeep b/changelog.d/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 00000000..c18b9d3b --- /dev/null +++ b/constraints.txt @@ -0,0 +1,66 @@ +alabaster==0.7.16 +annotated-types-0.7.0 +attrs==25.3.0 +babel==2.17.0 +backports.asyncio.runner==1.2.0 +backports.tarfile==1.2.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +check-wheel-contents==0.6.1 +cffi==2.0.0 +click==8.1.8 +coverage==7.10.6 +cryptography==46.0.1 +docutils==0.21.2 +exceptiongroup==1.3.0 +hypothesis==6.140.2 +iniconfig==2.1.0 +id==1.5.0 +idna==3.10 +imagesize==1.4.1 +importlib_metadata==8.7.0 +iniconfig==2.1.0 +jaraco.classes==3.4.0 +jaraco.context==6.0.1 +jaraco.functools==4.3.0 +jeepney==0.9.0 +Jinja2==3.1.6 +keyring==25.6.0 +markdown-it-py==3.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +more-itertools==10.8.0 +nh3==0.3.0 +packaging==25.0 +pluggy==1.6.0 +Pygments==2.19.2 +pycparser==2.23 +pydantic==2.11.9 +pydantic-core==2.33.2 +pytest==8.4.2 +readme-renderer==44.0 +requests==2.32.5 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==14.1.0 +SecretStorage==3.3.3 +setuptools==80.9.0 +setuptools-scm==9.2.0 +snowballstemmer==3.0.1 +sortedcontainers==2.4.0 +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==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +tomli==2.2.1 +twine==6.2.0 +typing_extensions==4.15.0 +typing-inspection==0.4.1 +urllib3==2.5.0 +wheel-filename==1.4.2 +zipp==3.23.0 diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt deleted file mode 100644 index 74eee9b6..00000000 --- a/dependencies/default/constraints.txt +++ /dev/null @@ -1,23 +0,0 @@ -async-generator==1.10 -attrs==23.1.0 -coverage==7.3.2 -exceptiongroup==1.1.3 -flaky==3.7.0 -hypothesis==6.88.1 -idna==3.4 -importlib-metadata==6.8.0 -iniconfig==2.0.0 -mypy==1.6.1 -mypy-extensions==1.0.0 -outcome==1.3.0.post0 -packaging==23.2 -pluggy==1.3.0 -pyparsing==3.1.1 -pytest==7.4.3 -pytest-trio==0.8.0 -sniffio==1.3.0 -sortedcontainers==2.4.0 -tomli==2.0.1 -trio==0.22.2 -typed-ast==1.5.5 -zipp==3.17.0 diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt deleted file mode 100644 index 0828607f..00000000 --- a/dependencies/default/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Always adjust install_requires in setup.cfg and pytest-min-requirements.txt -# when changing runtime dependencies -pytest >= 7.0.0 diff --git a/dependencies/docs/constraints.txt b/dependencies/docs/constraints.txt new file mode 100644 index 00000000..8d2df620 --- /dev/null +++ b/dependencies/docs/constraints.txt @@ -0,0 +1,23 @@ +alabaster==0.7.16 +Babel==2.17.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +docutils==0.21.2 +idna==3.10 +imagesize==1.4.1 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==25.0 +Pygments==2.19.2 +requests==2.32.5 +snowballstemmer==3.0.1 +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==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +urllib3==2.5.0 diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt index 1f82dbaf..f01a0eb7 100644 --- a/dependencies/pytest-min/constraints.txt +++ b/dependencies/pytest-min/constraints.txt @@ -1,22 +1,22 @@ -argcomplete==2.0.0 -attrs==22.1.0 -certifi==2022.9.24 -charset-normalizer==2.1.1 -elementpath==3.0.2 -exceptiongroup==1.0.0rc9 -hypothesis==6.56.3 +argcomplete==3.1.2 +attrs==23.1.0 +certifi==2023.7.22 +charset-normalizer==3.3.1 +coverage==7.3.2 +elementpath==4.1.5 +exceptiongroup==1.1.3 +hypothesis==6.88.3 idna==3.4 -iniconfig==1.1.1 -mock==4.0.3 +iniconfig==2.0.0 +mock==5.1.0 nose==1.3.7 -packaging==21.3 -pluggy==1.0.0 +packaging==23.2 +pluggy==1.5.0 py==1.11.0 -Pygments==2.13.0 -pyparsing==3.0.9 -pytest==7.0.0 -requests==2.28.1 +Pygments==2.16.1 +pytest==8.2.0 +requests==2.31.0 sortedcontainers==2.4.0 tomli==2.0.1 -urllib3==1.26.12 -xmlschema==2.1.1 +urllib3==2.0.7 +xmlschema==2.5.0 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/concepts.rst b/docs/concepts.rst new file mode 100644 index 00000000..591059a8 --- /dev/null +++ b/docs/concepts.rst @@ -0,0 +1,85 @@ +======== +Concepts +======== + +.. _concepts/event_loops: + +asyncio event loops +=================== +In order to understand how pytest-asyncio works, it helps to understand how pytest collectors work. +If you already know about pytest collectors, please :ref:`skip ahead `. +Otherwise, continue reading. +Let's assume we have a test suite with a file named *test_all_the_things.py* holding a single test, async or not: + +.. include:: concepts_function_scope_example.py + :code: python + +The file *test_all_the_things.py* is a Python module with a Python test function. +When we run pytest, the test runner descends into Python packages, modules, and classes, in order to find all tests, regardless whether the tests will run or not. +This process is referred to as *test collection* by pytest. +In our particular example, pytest will find our test module and the test function. +We can visualize the collection result by running ``pytest --collect-only``:: + + + + +The example illustrates that the code of our test suite is hierarchical. +Pytest uses so called *collectors* for each level of the hierarchy. +Our contrived example test suite uses the *Module* and *Function* collectors, but real world test code may contain additional hierarchy levels via the *Package* or *Class* collectors. +There's also a special *Session* collector at the root of the hierarchy. +You may notice that the individual levels resemble the possible `scopes of a pytest fixture. `__ + +.. _pytest-asyncio-event-loops: + +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 *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 + :code: python + +It's highly recommended for neighboring tests to use the same event loop scope. +For example, all tests in a class or module should use the same scope. +Assigning neighboring tests to different event loop scopes is discouraged as it can make test code hard to follow. + +Test discovery modes +==================== + +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 +----------- + +In strict mode pytest-asyncio will only run tests that have the *asyncio* marker and will only evaluate async fixtures decorated with ``@pytest_asyncio.fixture``. Test functions and fixtures without these markers and decorators will not be handled by pytest-asyncio. + +This mode is intended for projects that want so support multiple asynchronous programming libraries as it allows pytest-asyncio to coexist with other async testing plugins in the same codebase. + +Pytest automatically enables installed plugins. As a result pytest plugins need to coexist peacefully in their default configuration. This is why strict mode is the default mode. + +Auto mode +--------- + +In *auto* mode pytest-asyncio automatically adds the *asyncio* marker to all asynchronous test functions. It will also take ownership of all async fixtures, regardless of whether they are decorated with ``@pytest.fixture`` or ``@pytest_asyncio.fixture``. + +This mode is intended for projects that use *asyncio* as their only asynchronous programming library. Auto mode makes for the simplest test and fixture configuration and is the recommended default. + +If you intend to support multiple asynchronous programming libraries, e.g. *asyncio* and *trio*, strict mode will be the preferred option. + +.. _concepts/concurrent_execution: + +Test execution and concurrency +============================== + +pytest-asyncio runs async tests sequentially, just like how pytest runs synchronous tests. Each asynchronous test runs within its assigned event loop. For example, consider the following two tests: + +.. include:: concepts_concurrent_execution_example.py + :code: python + +This sequential execution is intentional and important for maintaining test isolation. Running tests concurrently could introduce race conditions and side effects where one test could interfere with another, making test results unreliable and difficult to debug. diff --git a/docs/concepts_concurrent_execution_example.py b/docs/concepts_concurrent_execution_example.py new file mode 100644 index 00000000..e573ca27 --- /dev/null +++ b/docs/concepts_concurrent_execution_example.py @@ -0,0 +1,16 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +async def test_first(): + await asyncio.sleep(2) # Takes 2 seconds + + +@pytest.mark.asyncio +async def test_second(): + await asyncio.sleep(2) # Takes 2 seconds + + +# Total execution time: ~4 seconds, not ~2 seconds diff --git a/docs/concepts_function_scope_example.py b/docs/concepts_function_scope_example.py new file mode 100644 index 00000000..1506ecf7 --- /dev/null +++ b/docs/concepts_function_scope_example.py @@ -0,0 +1,8 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +async def test_runs_in_a_loop(): + assert asyncio.get_running_loop() diff --git a/docs/concepts_module_scope_example.py b/docs/concepts_module_scope_example.py new file mode 100644 index 00000000..b83181b4 --- /dev/null +++ b/docs/concepts_module_scope_example.py @@ -0,0 +1,17 @@ +import asyncio + +import pytest + +loop: asyncio.AbstractEventLoop + + +@pytest.mark.asyncio(loop_scope="module") +async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + +@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 85% rename from docs/source/conf.py rename to docs/conf.py index b61a6679..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 = "2022, pytest-asyncio contributors" +copyright = "2023, pytest-asyncio contributors" author = "Tin Tvrtković" -release = "v0.20.1" +release = importlib.metadata.version(project) # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -24,4 +26,4 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] +html_static_path = [] 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/how-to-guides/class_scoped_loop_example.py b/docs/how-to-guides/class_scoped_loop_example.py new file mode 100644 index 00000000..7ffc4b1f --- /dev/null +++ b/docs/how-to-guides/class_scoped_loop_example.py @@ -0,0 +1,14 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio(loop_scope="class") +class TestInOneEventLoopPerClass: + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(self): + TestInOneEventLoopPerClass.loop = asyncio.get_running_loop() + + async def test_assert_same_loop(self): + assert asyncio.get_running_loop() is TestInOneEventLoopPerClass.loop diff --git a/docs/how-to-guides/index.rst b/docs/how-to-guides/index.rst new file mode 100644 index 00000000..2dadc881 --- /dev/null +++ b/docs/how-to-guides/index.rst @@ -0,0 +1,21 @@ +============= +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 + multiple_loops + parametrize_with_asyncio + uvloop + test_item_is_async + +This section of the documentation provides code snippets and recipes to accomplish specific tasks with pytest-asyncio. 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/how-to-guides/module_scoped_loop_example.py b/docs/how-to-guides/module_scoped_loop_example.py new file mode 100644 index 00000000..38ba8bdc --- /dev/null +++ b/docs/how-to-guides/module_scoped_loop_example.py @@ -0,0 +1,17 @@ +import asyncio + +import pytest + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +loop: asyncio.AbstractEventLoop + + +async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + +async def test_assert_same_loop(): + global loop + assert asyncio.get_running_loop() is loop diff --git a/docs/how-to-guides/multiple_loops.rst b/docs/how-to-guides/multiple_loops.rst new file mode 100644 index 00000000..3453c49f --- /dev/null +++ b/docs/how-to-guides/multiple_loops.rst @@ -0,0 +1,10 @@ +====================================== +How to test with different event loops +====================================== + +Parametrizing the *event_loop_policy* fixture parametrizes all async tests. The following example causes all async tests to run multiple times, once for each event loop in the fixture parameters: + +.. include:: multiple_loops_example.py + :code: python + +You may choose to limit the scope of the fixture to *package,* *module,* or *class,* if you only want a subset of your tests to run with different event loops. diff --git a/docs/how-to-guides/multiple_loops_example.py b/docs/how-to-guides/multiple_loops_example.py new file mode 100644 index 00000000..2083e8b6 --- /dev/null +++ b/docs/how-to-guides/multiple_loops_example.py @@ -0,0 +1,29 @@ +import asyncio +import warnings + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from asyncio import DefaultEventLoopPolicy + +import pytest + + +class CustomEventLoopPolicy(DefaultEventLoopPolicy): + pass + + +@pytest.fixture( + scope="session", + params=( + CustomEventLoopPolicy(), + CustomEventLoopPolicy(), + ), +) +def event_loop_policy(request): + return request.param + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_uses_custom_event_loop_policy(): + assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) 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/how-to-guides/parametrize_with_asyncio.rst b/docs/how-to-guides/parametrize_with_asyncio.rst new file mode 100644 index 00000000..b965cb7d --- /dev/null +++ b/docs/how-to-guides/parametrize_with_asyncio.rst @@ -0,0 +1,11 @@ +===================================== +How to parametrize asynchronous tests +===================================== + +The ``pytest.mark.parametrize`` marker works with asynchronous tests the same as with synchronous tests. You can apply both ``pytest.mark.asyncio`` and ``pytest.mark.parametrize`` to asynchronous test functions: + +.. include:: parametrize_with_asyncio_example.py + :code: python + +.. note:: + Whilst asynchronous tests can be parametrized, each individual test case still runs sequentially, not concurrently. For more information about how pytest-asyncio executes tests, see :ref:`concepts/concurrent_execution`. diff --git a/docs/how-to-guides/parametrize_with_asyncio_example.py b/docs/how-to-guides/parametrize_with_asyncio_example.py new file mode 100644 index 00000000..54f181be --- /dev/null +++ b/docs/how-to-guides/parametrize_with_asyncio_example.py @@ -0,0 +1,10 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [1, 2, 3]) +async def test_parametrized_async_function(value): + await asyncio.sleep(1) + assert value > 0 diff --git a/docs/how-to-guides/run_class_tests_in_same_loop.rst b/docs/how-to-guides/run_class_tests_in_same_loop.rst new file mode 100644 index 00000000..2ba40683 --- /dev/null +++ b/docs/how-to-guides/run_class_tests_in_same_loop.rst @@ -0,0 +1,8 @@ +====================================================== +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(loop_scope="class")``. +This is easily achieved by using the *asyncio* marker as a class decorator. + +.. include:: class_scoped_loop_example.py + :code: python diff --git a/docs/how-to-guides/run_module_tests_in_same_loop.rst b/docs/how-to-guides/run_module_tests_in_same_loop.rst new file mode 100644 index 00000000..c07de737 --- /dev/null +++ b/docs/how-to-guides/run_module_tests_in_same_loop.rst @@ -0,0 +1,8 @@ +======================================================= +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(loop_scope="module")``. +This is easily achieved by adding a `pytestmark` statement to your module. + +.. include:: module_scoped_loop_example.py + :code: python diff --git a/docs/how-to-guides/run_package_tests_in_same_loop.rst b/docs/how-to-guides/run_package_tests_in_same_loop.rst new file mode 100644 index 00000000..0392693f --- /dev/null +++ b/docs/how-to-guides/run_package_tests_in_same_loop.rst @@ -0,0 +1,11 @@ +======================================================== +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(loop_scope="package")``. +Add the following code to the ``__init__.py`` of the test package: + +.. include:: package_scoped_loop_example.py + :code: python + +Note that this marker is not passed down to tests in subpackages. +Subpackages constitute their own, separate package. diff --git a/docs/how-to-guides/test_item_is_async.rst b/docs/how-to-guides/test_item_is_async.rst new file mode 100644 index 00000000..a9ea5d40 --- /dev/null +++ b/docs/how-to-guides/test_item_is_async.rst @@ -0,0 +1,7 @@ +======================================= +How to tell if a test function is async +======================================= +Use ``pytest_asyncio.is_async_item`` to determine if a test item is asynchronous and managed by pytest-asyncio. + +.. include:: test_item_is_async_example.py + :code: python diff --git a/docs/how-to-guides/test_item_is_async_example.py b/docs/how-to-guides/test_item_is_async_example.py new file mode 100644 index 00000000..31b44193 --- /dev/null +++ b/docs/how-to-guides/test_item_is_async_example.py @@ -0,0 +1,7 @@ +from pytest_asyncio import is_async_test + + +def pytest_collection_modifyitems(items): + for item in items: + if is_async_test(item): + pass diff --git a/docs/how-to-guides/uvloop.rst b/docs/how-to-guides/uvloop.rst new file mode 100644 index 00000000..a796bea7 --- /dev/null +++ b/docs/how-to-guides/uvloop.rst @@ -0,0 +1,18 @@ +======================= +How to test with uvloop +======================= + +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 + + import pytest + import uvloop + + + @pytest.fixture(scope="session") + def event_loop_policy(): + return uvloop.EventLoopPolicy() + +You may choose to limit the scope of the fixture to *package,* *module,* or *class,* if you only want a subset of your tests to run with uvloop. 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/reference/changelog.rst b/docs/reference/changelog.rst new file mode 100644 index 00000000..f5f0b189 --- /dev/null +++ b/docs/reference/changelog.rst @@ -0,0 +1,435 @@ +========= +Changelog +========= + +All notable changes to this project will be documented in this file. + +The format is based on `Keep a Changelog `__, and this project adheres to `Semantic Versioning `__. + +This project uses `towncrier `__ for changlog management and the changes for the upcoming release can be found in https://github.com/pytest-dev/pytest-asyncio/tree/main/changelog.d/. + +.. towncrier release notes start + +`1.2.0 `_ - 2025-09-12 +=============================================================================== + +Added +----- + +- ``--asyncio-debug`` CLI option and ``asyncio_debug`` configuration option to enable asyncio debug mode for the default event loop. (`#980 `_) +- A ``pytest.UsageError`` for invalid configuration values of ``asyncio_default_fixture_loop_scope`` and ``asyncio_default_test_loop_scope``. (`#1189 `_) +- Compatibility with the `Pyright` type checker (`#731 `_) + + +Fixed +----- + +- ``RuntimeError: There is no current event loop in thread 'MainThread'`` when any test unsets the event loop (such as when using ``asyncio.run`` and ``asyncio.Runner``). (`#1177 `_) +- Deprecation warning when decorating an asynchronous fixture with ``@pytest.fixture`` in `strict` mode. The warning message now refers to the correct package. (`#1198 `_) + + +Notes for Downstream Packagers +------------------------------ + +- Bump the minimum required version of tox to v4.28. This change is only relevant if you use the ``tox.ini`` file provided by pytest-asyncio to run tests. +- Extend dependency on typing-extensions>=4.12 from Python<3.10 to Python<3.13. + + +`1.1.0 `_ - 2025-07-16 +=============================================================================== + +Added +----- + +- Propagation of ContextVars from async fixtures to other fixtures and tests on Python 3.10 and older (`#127 `_) +- Cancellation of tasks when the `loop_scope` ends (`#200 `_) +- Warning when the current event loop is closed by a test + + +Fixed +----- + +- Error about missing loop when calling functions requiring a loop in the `finally` clause of a task (`#878 `_) +- An error that could cause duplicate warnings to be issued + + +Notes for Downstream Packagers +------------------------------ + +- Added runtime dependency on `backports.asyncio.runner `__ for use with Python 3.10 and older + + +`1.0.0 `_ - 2025-05-26 +=============================================================================== + +Removed +------- + +- The deprecated *event_loop* fixture. (`#1106 `_) + + +Added +----- + +- Prelimiary support for Python 3.14 (`#1025 `_) + + +Changed +------- + +- Scoped event loops (e.g. module-scoped loops) are created once rather than per scope (e.g. per module). This reduces the number of fixtures and speeds up collection time, especially for large test suites. (`#1107 `_) +- The *loop_scope* argument to ``pytest.mark.asyncio`` no longer forces that a pytest Collector exists at the level of the specified scope. For example, a test function marked with ``pytest.mark.asyncio(loop_scope="class")`` no longer requires a class surrounding the test. This is consistent with the behavior of the *scope* argument to ``pytest_asyncio.fixture``. (`#1112 `_) + + +Fixed +----- + +- An error caused when using pytest's `--setup-plan` option. (`#630 `_) +- Unsuppressed import errors with pytest option ``--doctest-ignore-import-errors`` (`#797 `_) +- A "fixture not found" error in connection with package-scoped loops (`#1052 `_) + + +Notes for Downstream Packagers +------------------------------ + +- Removed a test that had an ordering dependency on other tests. (`#1114 `_) + + +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 pytest-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 `_ +- Declare support for Python 3.13 + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + +0.23.7 (2024-05-19) +=================== +- Silence deprecation warnings about unclosed event loops that occurred with certain CPython patch releases `#817 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + +0.23.6 (2024-03-19) +=================== +- Fix compatibility with pytest 8.2 `#800 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + +0.23.5 (2024-02-09) +=================== +- Declare compatibility with pytest 8 `#737 `_ +- Fix typing errors with recent versions of mypy `#769 `_ +- Prevent DeprecationWarning about internal use of `asyncio.get_event_loop()` from affecting test cases `#757 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + +0.23.4 (2024-01-28) +=================== +- pytest-asyncio no longer imports additional, unrelated packages during test collection `#729 `_ +- Addresses further issues that caused an internal pytest error during test collection +- Declares incompatibility with pytest 8 `#737 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + +0.23.3 (2024-01-01) +=================== +- Fixes a bug that caused event loops to be closed prematurely when using async generator fixtures with class scope or wider in a function-scoped test `#706 `_ +- Fixes various bugs that caused an internal pytest error during test collection `#711 `_ `#713 `_ `#719 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + +0.23.2 (2023-12-04) +=================== +- Fixes a bug that caused an internal pytest error when collecting .txt files `#703 `_ + + +0.23.1 (2023-12-03) +=================== +- Fixes a bug that caused an internal pytest error when using module-level skips `#701 `_ + + +0.23.0 (2023-12-03) +=================== +This release is backwards-compatible with v0.21. +Changes are non-breaking, unless you upgrade from v0.22. + +- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module, package, and session scopes can be requested via the *scope* keyword argument to the _asyncio_ mark. +- Introduces the *event_loop_policy* fixture which allows testing with non-default or multiple event loops `#662 `_ +- Introduces ``pytest_asyncio.is_async_test`` which returns whether a test item is managed by pytest-asyncio `#376 `_ +- Removes and *pytest-trio,* *mypy,* and *flaky* from the test dependencies `#620 `_, `#674 `_, `#678 `_, + +0.22.0 (2023-10-31) +=================== +This release has been yanked from PyPI due to fundamental issues with the _asyncio_event_loop_ mark. + +- Class-scoped and module-scoped event loops can be requested + via the _asyncio_event_loop_ mark. `#620 `_ +- Deprecate redefinition of the `event_loop` fixture. `#587 `_ + Users requiring a class-scoped or module-scoped asyncio event loop for their tests + should mark the corresponding class or module with `asyncio_event_loop`. +- Test items based on asynchronous generators always exit with *xfail* status and emit a warning during the collection phase. This behavior is consistent with synchronous yield tests. `#642 `__ +- Remove support for Python 3.7 +- Declare support for Python 3.12 + +0.21.2 (2024-04-29) +=================== +- Fix compatibility with pytest 8.2. Backport of `#800 `_ to pytest-asyncio v0.21 for users who are unable to upgrade to a more recent version (see `#706`_) + +0.21.1 (2023-07-12) +=================== +- Output a proper error message when an invalid ``asyncio_mode`` is selected. +- Extend warning message about unclosed event loops with additional possible cause. + `#531 `_ +- Previously, some tests reported "skipped" or "xfailed" as a result. Now all tests report a "success" result. + +0.21.0 (2023-03-19) +=================== +- Drop compatibility with pytest 6.1. Pytest-asyncio now depends on pytest 7.0 or newer. +- pytest-asyncio cleans up any stale event loops when setting up and tearing down the + event_loop fixture. This behavior has been deprecated and pytest-asyncio emits a + DeprecationWarning when tearing down the event_loop fixture and current event loop + has not been closed. + +0.20.3 (2022-12-08) +=================== +- Prevent DeprecationWarning to bubble up on CPython 3.10.9 and 3.11.1. + `#460 `_ + +0.20.2 (2022-11-11) +=================== +- Fixes an issue with async fixtures that are defined as methods on a test class not being rebound to the actual test instance. `#197 `_ +- Replaced usage of deprecated ``@pytest.mark.tryfirst`` with ``@pytest.hookimpl(tryfirst=True)`` `#438 `_ + +0.20.1 (2022-10-21) +=================== +- Fixes an issue that warned about using an old version of pytest, even though the most recent version was installed. `#430 `_ + +0.20.0 (2022-10-21) +=================== +- BREAKING: Removed *legacy* mode. If you're upgrading from v0.19 and you haven't configured ``asyncio_mode = legacy``, you can upgrade without taking any additional action. If you're upgrading from an earlier version or you have explicitly enabled *legacy* mode, you need to switch to *auto* or *strict* mode before upgrading to this version. +- Deprecate use of pytest v6. +- Fixed an issue which prevented fixture setup from being cached. `#404 `_ + +0.19.0 (2022-07-13) +=================== +- BREAKING: The default ``asyncio_mode`` is now *strict*. `#293 `_ +- Removes `setup.py` since all relevant configuration is present `setup.cfg`. Users requiring an editable installation of pytest-asyncio need to use pip v21.1 or newer. `#283 `_ +- Declare support for Python 3.11. + +0.18.3 (2022-03-25) +=================== +- Adds `pytest-trio `_ to the test dependencies +- Fixes a bug that caused pytest-asyncio to try to set up async pytest_trio fixtures in strict mode. `#298 `_ + +0.18.2 (2022-03-03) +=================== +- Fix asyncio auto mode not marking static methods. `#295 `_ +- Fix a compatibility issue with Hypothesis 6.39.0. `#302 `_ + +0.18.1 (2022-02-10) +=================== +- Fixes a regression that prevented async fixtures from working in synchronous tests. `#286 `_ + +0.18.0 (2022-02-07) +=================== + +- Raise a warning if @pytest.mark.asyncio is applied to non-async function. `#275 `_ +- Support parametrized ``event_loop`` fixture. `#278 `_ + +0.17.2 (2022-01-17) +=================== + +- Require ``typing-extensions`` on Python<3.8 only. `#269 `_ +- Fix a regression in tests collection introduced by 0.17.1, the plugin works fine with non-python tests again. `#267 `_ + + +0.17.1 (2022-01-16) +=================== +- Fixes a bug that prevents async Hypothesis tests from working without explicit ``asyncio`` marker when ``--asyncio-mode=auto`` is set. `#258 `_ +- Fixed a bug that closes the default event loop if the loop doesn't exist `#257 `_ +- Added type annotations. `#198 `_ +- Show asyncio mode in pytest report headers. `#266 `_ +- Relax ``asyncio_mode`` type definition; it allows to support pytest 6.1+. `#262 `_ + +0.17.0 (2022-01-13) +=================== +- `pytest-asyncio` no longer alters existing event loop policies. `#168 `_, `#188 `_ +- Drop support for Python 3.6 +- Fixed an issue when pytest-asyncio was used in combination with `flaky` or inherited asynchronous Hypothesis tests. `#178 `_ `#231 `_ +- Added `flaky `_ to test dependencies +- Added ``unused_udp_port`` and ``unused_udp_port_factory`` fixtures (similar to ``unused_tcp_port`` and ``unused_tcp_port_factory`` counterparts. `#99 `_ +- Added the plugin modes: *strict*, *auto*, and *legacy*. See `documentation `_ for details. `#125 `_ +- Correctly process ``KeyboardInterrupt`` during async fixture setup phase `#219 `_ + +0.16.0 (2021-10-16) +=================== +- Add support for Python 3.10 + +0.15.1 (2021-04-22) +=================== +- Hotfix for errors while closing event loops while replacing them. + `#209 `_ + `#210 `_ + +0.15.0 (2021-04-19) +=================== +- Add support for Python 3.9 +- Abandon support for Python 3.5. If you still require support for Python 3.5, please use pytest-asyncio v0.14 or earlier. +- Set ``unused_tcp_port_factory`` fixture scope to 'session'. + `#163 `_ +- Properly close event loops when replacing them. + `#208 `_ + +0.14.0 (2020-06-24) +=================== +- Fix `#162 `_, and ``event_loop`` fixture behavior now is coherent on all scopes. + `#164 `_ + +0.12.0 (2020-05-04) +=================== +- Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. + `#156 `_ + +0.11.0 (2020-04-20) +=================== +- Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. + `#152 `_ +- Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. + `#142 `_ +- Better ``pytest.skip`` support. + `#126 `_ + +0.10.0 (2019-01-08) +==================== +- ``pytest-asyncio`` integrates with `Hypothesis `_ + to support ``@given`` on async test functions using ``asyncio``. + `#102 `_ +- Pytest 4.1 support. + `#105 `_ + +0.9.0 (2018-07-28) +================== +- Python 3.7 support. +- Remove ``event_loop_process_pool`` fixture and + ``pytest.mark.asyncio_process_pool`` marker (see + https://bugs.python.org/issue34075 for deprecation and removal details) + +0.8.0 (2017-09-23) +================== +- Improve integration with other packages (like aiohttp) with more careful event loop handling. + `#64 `_ + +0.7.0 (2017-09-08) +================== +- Python versions pre-3.6 can use the async_generator library for async fixtures. + `#62 ` + +0.6.0 (2017-05-28) +================== +- Support for Python versions pre-3.5 has been dropped. +- ``pytestmark`` now works on both module and class level. +- The ``forbid_global_loop`` parameter has been removed. +- Support for async and async gen fixtures has been added. + `#45 `_ +- The deprecation warning regarding ``asyncio.async()`` has been fixed. + `#51 `_ + +0.5.0 (2016-09-07) +================== +- Introduced a changelog. + `#31 `_ +- The ``event_loop`` fixture is again responsible for closing itself. + This makes the fixture slightly harder to correctly override, but enables + other fixtures to depend on it correctly. + `#30 `_ +- Deal with the event loop policy by wrapping a special pytest hook, + ``pytest_fixture_setup``. This allows setting the policy before fixtures + dependent on the ``event_loop`` fixture run, thus allowing them to take + advantage of the ``forbid_global_loop`` parameter. As a consequence of this, + we now depend on pytest 3.0. + `#29 `_ + +0.4.1 (2016-06-01) +================== +- Fix a bug preventing the propagation of exceptions from the plugin. + `#25 `_ + +0.4.0 (2016-05-30) +================== +- Make ``event_loop`` fixtures simpler to override by closing them in the + plugin, instead of directly in the fixture. + `#21 `_ +- Introduce the ``forbid_global_loop`` parameter. + `#21 `_ + +0.3.0 (2015-12-19) +================== +- Support for Python 3.5 ``async``/``await`` syntax. + `#17 `_ + +0.2.0 (2015-08-01) +================== +- ``unused_tcp_port_factory`` fixture. + `#10 `_ + +0.1.1 (2015-04-23) +================== +Initial release. diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst new file mode 100644 index 00000000..7fef0af4 --- /dev/null +++ b/docs/reference/configuration.rst @@ -0,0 +1,58 @@ +============= +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`` + +.. _configuration/asyncio_debug: + +asyncio_debug +============= +Enables `asyncio debug mode `_ for the default event loop used by asynchronous tests and fixtures. + +The debug mode can be set by the ``asyncio_debug`` configuration option in the `configuration file +`_: + +.. code-block:: ini + + # pytest.ini + [pytest] + asyncio_debug = true + +The value can also be set via the ``--asyncio-debug`` command-line option: + +.. code-block:: bash + + $ pytest tests --asyncio-debug + +By default, asyncio debug mode is disabled. + +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/reference/fixtures/event_loop_policy_example.py b/docs/reference/fixtures/event_loop_policy_example.py new file mode 100644 index 00000000..e8642527 --- /dev/null +++ b/docs/reference/fixtures/event_loop_policy_example.py @@ -0,0 +1,23 @@ +import asyncio +import warnings + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from asyncio import DefaultEventLoopPolicy + +import pytest + + +class CustomEventLoopPolicy(DefaultEventLoopPolicy): + pass + + +@pytest.fixture(scope="module") +def event_loop_policy(request): + return CustomEventLoopPolicy() + + +@pytest.mark.asyncio(loop_scope="module") +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_uses_custom_event_loop_policy(): + assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) diff --git a/docs/reference/fixtures/event_loop_policy_parametrized_example.py b/docs/reference/fixtures/event_loop_policy_parametrized_example.py new file mode 100644 index 00000000..19552d81 --- /dev/null +++ b/docs/reference/fixtures/event_loop_policy_parametrized_example.py @@ -0,0 +1,28 @@ +import asyncio +import warnings + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from asyncio import DefaultEventLoopPolicy + +import pytest + + +class CustomEventLoopPolicy(DefaultEventLoopPolicy): + pass + + +@pytest.fixture( + params=( + DefaultEventLoopPolicy(), + CustomEventLoopPolicy(), + ), +) +def event_loop_policy(request): + return request.param + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_uses_custom_event_loop_policy(): + assert isinstance(asyncio.get_event_loop_policy(), DefaultEventLoopPolicy) diff --git a/docs/reference/fixtures/index.rst b/docs/reference/fixtures/index.rst new file mode 100644 index 00000000..3d151dcb --- /dev/null +++ b/docs/reference/fixtures/index.rst @@ -0,0 +1,40 @@ +======== +Fixtures +======== + +event_loop_policy +================= +Returns the event loop policy used to create asyncio event loops. +The default return value is *asyncio.get_event_loop_policy().* + +This fixture can be overridden when a different event loop policy should be used. + +.. include:: event_loop_policy_example.py + :code: python + +Multiple policies can be provided via fixture parameters. +The fixture is automatically applied to all pytest-asyncio tests. +Therefore, all tests managed by pytest-asyncio are run once for each fixture parameter. +The following example runs the test with different event loop policies. + +.. include:: event_loop_policy_parametrized_example.py + :code: python + +unused_tcp_port +=============== +Finds and yields a single unused TCP port on the localhost interface. Useful for +binding temporary test servers. + +unused_tcp_port_factory +======================= +A callable which returns a different unused TCP port each invocation. Useful +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() + +unused_udp_port and unused_udp_port_factory +=========================================== +Works just like their TCP counterparts but returns unused UDP ports. diff --git a/docs/reference/functions.rst b/docs/reference/functions.rst new file mode 100644 index 00000000..fcd531c2 --- /dev/null +++ b/docs/reference/functions.rst @@ -0,0 +1,9 @@ +========= +Functions +========= + +is_async_test +============= +Returns whether a specific pytest Item is an asynchronous test managed by pytest-asyncio. + +This function is intended to be used in pytest hooks or by plugins that depend on pytest-asyncio. diff --git a/docs/source/reference/index.rst b/docs/reference/index.rst similarity index 96% rename from docs/source/reference/index.rst rename to docs/reference/index.rst index 5fdc2724..b24c6e9c 100644 --- a/docs/source/reference/index.rst +++ b/docs/reference/index.rst @@ -7,6 +7,7 @@ Reference configuration fixtures/index + functions markers/index decorators/index changelog diff --git a/docs/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py b/docs/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py new file mode 100644 index 00000000..5bb26247 --- /dev/null +++ b/docs/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py @@ -0,0 +1,23 @@ +import warnings + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from asyncio import DefaultEventLoopPolicy + +import pytest + + +@pytest.fixture( + params=[ + DefaultEventLoopPolicy(), + DefaultEventLoopPolicy(), + ] +) +def event_loop_policy(request): + return request.param + + +class TestWithDifferentLoopPolicies: + @pytest.mark.asyncio + async def test_parametrized_loop(self): + pass 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 79% 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 c33b34b8..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,14 +3,12 @@ import pytest -@pytest.mark.asyncio_event_loop +@pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio async def test_remember_loop(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop 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 c70a4bc6..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,14 +5,13 @@ import pytest_asyncio -@pytest.mark.asyncio_event_loop +@pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture + @pytest_asyncio.fixture(loop_scope="class") async def my_fixture(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_runs_is_same_loop_as_fixture(self, my_fixture): assert asyncio.get_running_loop() is TestClassScopedLoop.loop diff --git a/docs/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py b/docs/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py new file mode 100644 index 00000000..f8e7e717 --- /dev/null +++ b/docs/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py @@ -0,0 +1,10 @@ +import asyncio + +import pytest + +# Marks all test coroutines in this module +pytestmark = pytest.mark.asyncio + + +async def test_runs_in_asyncio_event_loop(): + assert asyncio.get_running_loop() diff --git a/docs/reference/markers/function_scoped_loop_strict_mode_example.py b/docs/reference/markers/function_scoped_loop_strict_mode_example.py new file mode 100644 index 00000000..e30f73c5 --- /dev/null +++ b/docs/reference/markers/function_scoped_loop_strict_mode_example.py @@ -0,0 +1,8 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +async def test_runs_in_asyncio_event_loop(): + assert asyncio.get_running_loop() diff --git a/docs/reference/markers/index.rst b/docs/reference/markers/index.rst new file mode 100644 index 00000000..7715077b --- /dev/null +++ b/docs/reference/markers/index.rst @@ -0,0 +1,42 @@ +======= +Markers +======= + +.. _reference/markers/asyncio: + +``pytest.mark.asyncio`` +======================= +A coroutine or async generator with this marker is treated as a test function by pytest. +The marked function is executed as an asyncio task in the event loop provided by pytest-asyncio. + +.. include:: function_scoped_loop_strict_mode_example.py + :code: python + +Multiple async tests in a single class or module can be marked using |pytestmark|_. + +.. include:: function_scoped_loop_pytestmark_strict_mode_example.py + :code: python + +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 *loop_scope* keyword argument to the *asyncio* mark. +The supported scopes are *function,* *class,* and *module,* *package,* and *session*. +The following code example provides a shared event loop for all tests in `TestClassScopedLoop`: + +.. include:: class_scoped_loop_strict_mode_example.py + :code: python + +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 + :code: python + +Subpackages do not share the loop with their parent package. + +Tests marked with *session* scope share the same event loop, even if the tests exist in different packages. + +.. |auto mode| replace:: *auto mode* +.. _auto mode: ../../concepts.html#auto-mode +.. |pytestmark| replace:: ``pytestmark`` +.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/docs/source/reference/markers/module_scoped_loop_auto_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_auto_mode_example.py rename to docs/reference/markers/module_scoped_loop_strict_mode_example.py index e38bdeff..cece90db 100644 --- a/docs/source/reference/markers/module_scoped_loop_auto_mode_example.py +++ b/docs/reference/markers/module_scoped_loop_strict_mode_example.py @@ -2,7 +2,7 @@ import pytest -pytestmark = pytest.mark.asyncio_event_loop +pytestmark = pytest.mark.asyncio(loop_scope="module") loop: asyncio.AbstractEventLoop diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst deleted file mode 100644 index e774791e..00000000 --- a/docs/source/concepts.rst +++ /dev/null @@ -1,43 +0,0 @@ -======== -Concepts -======== - -asyncio event loops -=================== -pytest-asyncio runs each test item in its own asyncio event loop. The loop can be accessed via ``asyncio.get_running_loop()``. - -.. code-block:: python - - async def test_runs_in_a_loop(): - assert asyncio.get_running_loop() - -Synchronous test functions can get access to an asyncio event loop via the `event_loop` fixture. - -.. code-block:: python - - def test_can_access_current_loop(event_loop): - assert event_loop - -Test discovery modes -==================== - -Pytest-asyncio provides two modes for test discovery, *strict* and *auto*. - - -Strict mode ------------ - -In strict mode pytest-asyncio will only run tests that have the *asyncio* marker and will only evaluate async fixtures decorated with ``@pytest_asyncio.fixture``. Test functions and fixtures without these markers and decorators will not be handled by pytest-asyncio. - -This mode is intended for projects that want so support multiple asynchronous programming libraries as it allows pytest-asyncio to coexist with other async testing plugins in the same codebase. - -Pytest automatically enables installed plugins. As a result pytest plugins need to coexist peacefully in their default configuration. This is why strict mode is the default mode. - -Auto mode ---------- - -In *auto* mode pytest-asyncio automatically adds the *asyncio* marker to all asynchronous test functions. It will also take ownership of all async fixtures, regardless of whether they are decorated with ``@pytest.fixture`` or ``@pytest_asyncio.fixture``. - -This mode is intended for projects that use *asyncio* as their only asynchronous programming library. Auto mode makes for the simplest test and fixture configuration and is the recommended default. - -If you intend to support multiple asynchronous programming libraries, e.g. *asyncio* and *trio*, strict mode will be the preferred option. diff --git a/docs/source/how-to-guides/index.rst b/docs/source/how-to-guides/index.rst deleted file mode 100644 index 922fac91..00000000 --- a/docs/source/how-to-guides/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -============= -How-To Guides -============= - -.. toctree:: - :hidden: - - uvloop - -This section of the documentation provides code snippets and recipes to accomplish specific tasks with pytest-asyncio. diff --git a/docs/source/how-to-guides/uvloop.rst b/docs/source/how-to-guides/uvloop.rst deleted file mode 100644 index 14353365..00000000 --- a/docs/source/how-to-guides/uvloop.rst +++ /dev/null @@ -1,13 +0,0 @@ -======================= -How to test with uvloop -======================= - -Replace the default event loop policy in your *conftest.py:* - -.. code-block:: python - - import asyncio - - import uvloop - - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst deleted file mode 100644 index 7da71868..00000000 --- a/docs/source/reference/changelog.rst +++ /dev/null @@ -1,215 +0,0 @@ -========= -Changelog -========= - -1.0.0 (UNRELEASED) -================== -- Class-scoped and module-scoped event loops can be requested - via the _asyncio_event_loop_ mark. `#620 `_ -- Deprecate redefinition of the `event_loop` fixture. `#587 `_ - Users requiring a class-scoped or module-scoped asyncio event loop for their tests - should mark the corresponding class or module with `asyncio_event_loop`. -- Test items based on asynchronous generators always exit with *xfail* status and emit a warning during the collection phase. This behavior is consistent with synchronous yield tests. `#642 `__ -- Remove support for Python 3.7 -- Declare support for Python 3.12 - -0.21.1 (2023-07-12) -=================== -- Output a proper error message when an invalid ``asyncio_mode`` is selected. -- Extend warning message about unclosed event loops with additional possible cause. - `#531 `_ -- Previously, some tests reported "skipped" or "xfailed" as a result. Now all tests report a "success" result. - -0.21.0 (2023-03-19) -=================== -- Drop compatibility with pytest 6.1. Pytest-asyncio now depends on pytest 7.0 or newer. -- pytest-asyncio cleans up any stale event loops when setting up and tearing down the - event_loop fixture. This behavior has been deprecated and pytest-asyncio emits a - DeprecationWarning when tearing down the event_loop fixture and current event loop - has not been closed. - -0.20.3 (2022-12-08) -=================== -- Prevent DeprecationWarning to bubble up on CPython 3.10.9 and 3.11.1. - `#460 `_ - -0.20.2 (2022-11-11) -=================== -- Fixes an issue with async fixtures that are defined as methods on a test class not being rebound to the actual test instance. `#197 `_ -- Replaced usage of deprecated ``@pytest.mark.tryfirst`` with ``@pytest.hookimpl(tryfirst=True)`` `#438 `_ - -0.20.1 (2022-10-21) -=================== -- Fixes an issue that warned about using an old version of pytest, even though the most recent version was installed. `#430 `_ - -0.20.0 (2022-10-21) -=================== -- BREAKING: Removed *legacy* mode. If you're upgrading from v0.19 and you haven't configured ``asyncio_mode = legacy``, you can upgrade without taking any additional action. If you're upgrading from an earlier version or you have explicitly enabled *legacy* mode, you need to switch to *auto* or *strict* mode before upgrading to this version. -- Deprecate use of pytest v6. -- Fixed an issue which prevented fixture setup from being cached. `#404 `_ - -0.19.0 (2022-07-13) -=================== -- BREAKING: The default ``asyncio_mode`` is now *strict*. `#293 `_ -- Removes `setup.py` since all relevant configuration is present `setup.cfg`. Users requiring an editable installation of pytest-asyncio need to use pip v21.1 or newer. `#283 `_ -- Declare support for Python 3.11. - -0.18.3 (2022-03-25) -=================== -- Adds `pytest-trio `_ to the test dependencies -- Fixes a bug that caused pytest-asyncio to try to set up async pytest_trio fixtures in strict mode. `#298 `_ - -0.18.2 (2022-03-03) -=================== -- Fix asyncio auto mode not marking static methods. `#295 `_ -- Fix a compatibility issue with Hypothesis 6.39.0. `#302 `_ - -0.18.1 (2022-02-10) -=================== -- Fixes a regression that prevented async fixtures from working in synchronous tests. `#286 `_ - -0.18.0 (2022-02-07) -=================== - -- Raise a warning if @pytest.mark.asyncio is applied to non-async function. `#275 `_ -- Support parametrized ``event_loop`` fixture. `#278 `_ - -0.17.2 (2022-01-17) -=================== - -- Require ``typing-extensions`` on Python<3.8 only. `#269 `_ -- Fix a regression in tests collection introduced by 0.17.1, the plugin works fine with non-python tests again. `#267 `_ - - -0.17.1 (2022-01-16) -=================== -- Fixes a bug that prevents async Hypothesis tests from working without explicit ``asyncio`` marker when ``--asyncio-mode=auto`` is set. `#258 `_ -- Fixed a bug that closes the default event loop if the loop doesn't exist `#257 `_ -- Added type annotations. `#198 `_ -- Show asyncio mode in pytest report headers. `#266 `_ -- Relax ``asyncio_mode`` type definition; it allows to support pytest 6.1+. `#262 `_ - -0.17.0 (2022-01-13) -=================== -- `pytest-asyncio` no longer alters existing event loop policies. `#168 `_, `#188 `_ -- Drop support for Python 3.6 -- Fixed an issue when pytest-asyncio was used in combination with `flaky` or inherited asynchronous Hypothesis tests. `#178 `_ `#231 `_ -- Added `flaky `_ to test dependencies -- Added ``unused_udp_port`` and ``unused_udp_port_factory`` fixtures (similar to ``unused_tcp_port`` and ``unused_tcp_port_factory`` counterparts. `#99 `_ -- Added the plugin modes: *strict*, *auto*, and *legacy*. See `documentation `_ for details. `#125 `_ -- Correctly process ``KeyboardInterrupt`` during async fixture setup phase `#219 `_ - -0.16.0 (2021-10-16) -=================== -- Add support for Python 3.10 - -0.15.1 (2021-04-22) -=================== -- Hotfix for errors while closing event loops while replacing them. - `#209 `_ - `#210 `_ - -0.15.0 (2021-04-19) -=================== -- Add support for Python 3.9 -- Abandon support for Python 3.5. If you still require support for Python 3.5, please use pytest-asyncio v0.14 or earlier. -- Set ``unused_tcp_port_factory`` fixture scope to 'session'. - `#163 `_ -- Properly close event loops when replacing them. - `#208 `_ - -0.14.0 (2020-06-24) -=================== -- Fix `#162 `_, and ``event_loop`` fixture behavior now is coherent on all scopes. - `#164 `_ - -0.12.0 (2020-05-04) -=================== -- Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. - `#156 `_ - -0.11.0 (2020-04-20) -=================== -- Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. - `#152 `_ -- Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. - `#142 `_ -- Better ``pytest.skip`` support. - `#126 `_ - -0.10.0 (2019-01-08) -==================== -- ``pytest-asyncio`` integrates with `Hypothesis `_ - to support ``@given`` on async test functions using ``asyncio``. - `#102 `_ -- Pytest 4.1 support. - `#105 `_ - -0.9.0 (2018-07-28) -================== -- Python 3.7 support. -- Remove ``event_loop_process_pool`` fixture and - ``pytest.mark.asyncio_process_pool`` marker (see - https://bugs.python.org/issue34075 for deprecation and removal details) - -0.8.0 (2017-09-23) -================== -- Improve integration with other packages (like aiohttp) with more careful event loop handling. - `#64 `_ - -0.7.0 (2017-09-08) -================== -- Python versions pre-3.6 can use the async_generator library for async fixtures. - `#62 ` - -0.6.0 (2017-05-28) -================== -- Support for Python versions pre-3.5 has been dropped. -- ``pytestmark`` now works on both module and class level. -- The ``forbid_global_loop`` parameter has been removed. -- Support for async and async gen fixtures has been added. - `#45 `_ -- The deprecation warning regarding ``asyncio.async()`` has been fixed. - `#51 `_ - -0.5.0 (2016-09-07) -================== -- Introduced a changelog. - `#31 `_ -- The ``event_loop`` fixture is again responsible for closing itself. - This makes the fixture slightly harder to correctly override, but enables - other fixtures to depend on it correctly. - `#30 `_ -- Deal with the event loop policy by wrapping a special pytest hook, - ``pytest_fixture_setup``. This allows setting the policy before fixtures - dependent on the ``event_loop`` fixture run, thus allowing them to take - advantage of the ``forbid_global_loop`` parameter. As a consequence of this, - we now depend on pytest 3.0. - `#29 `_ - -0.4.1 (2016-06-01) -================== -- Fix a bug preventing the propagation of exceptions from the plugin. - `#25 `_ - -0.4.0 (2016-05-30) -================== -- Make ``event_loop`` fixtures simpler to override by closing them in the - plugin, instead of directly in the fixture. - `#21 `_ -- Introduce the ``forbid_global_loop`` parameter. - `#21 `_ - -0.3.0 (2015-12-19) -================== -- Support for Python 3.5 ``async``/``await`` syntax. - `#17 `_ - -0.2.0 (2015-08-01) -================== -- ``unused_tcp_port_factory`` fixture. - `#10 `_ - -0.1.1 (2015-04-23) -================== -Initial release. 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/reference/fixtures/event_loop_example.py b/docs/source/reference/fixtures/event_loop_example.py deleted file mode 100644 index b5a82b62..00000000 --- a/docs/source/reference/fixtures/event_loop_example.py +++ /dev/null @@ -1,5 +0,0 @@ -import asyncio - - -def test_event_loop_fixture(event_loop): - event_loop.run_until_complete(asyncio.sleep(0)) diff --git a/docs/source/reference/fixtures/index.rst b/docs/source/reference/fixtures/index.rst deleted file mode 100644 index c0bfd300..00000000 --- a/docs/source/reference/fixtures/index.rst +++ /dev/null @@ -1,43 +0,0 @@ -======== -Fixtures -======== - -event_loop -========== -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. 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 -is used to mark coroutines that should be treated as test functions. - -If your tests require an asyncio event loop with class or module scope, apply the `asyncio_event_loop mark <./markers.html/#pytest-mark-asyncio-event-loop>`__ to the respective class or module. - -If you need to change the type of the event loop, prefer setting a custom event loop policy over redefining the ``event_loop`` fixture. - -If the ``pytest.mark.asyncio`` decorator is applied to a test function, the ``event_loop`` -fixture will be requested automatically by the test function. - -unused_tcp_port -=============== -Finds and yields a single unused TCP port on the localhost interface. Useful for -binding temporary test servers. - -unused_tcp_port_factory -======================= -A callable which returns a different unused TCP port each invocation. Useful -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() - ... - -unused_udp_port and unused_udp_port_factory -=========================================== -Works just like their TCP counterparts but returns unused UDP ports. diff --git a/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py b/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py deleted file mode 100644 index a839e571..00000000 --- a/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py +++ /dev/null @@ -1,14 +0,0 @@ -import asyncio - -import pytest - - -@pytest.mark.asyncio_event_loop -class TestClassScopedLoop: - loop: asyncio.AbstractEventLoop - - async def test_remember_loop(self): - TestClassScopedLoop.loop = asyncio.get_running_loop() - - async def test_this_runs_in_same_loop(self): - assert asyncio.get_running_loop() is TestClassScopedLoop.loop diff --git a/docs/source/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py deleted file mode 100644 index 85ccc3a1..00000000 --- a/docs/source/reference/markers/class_scoped_loop_custom_policies_strict_mode_example.py +++ /dev/null @@ -1,15 +0,0 @@ -import asyncio - -import pytest - - -@pytest.mark.asyncio_event_loop( - policy=[ - asyncio.DefaultEventLoopPolicy(), - asyncio.DefaultEventLoopPolicy(), - ] -) -class TestWithDifferentLoopPolicies: - @pytest.mark.asyncio - async def test_parametrized_loop(self): - pass diff --git a/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py deleted file mode 100644 index b4525ca4..00000000 --- a/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py +++ /dev/null @@ -1,14 +0,0 @@ -import asyncio - -import pytest - - -class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - pass - - -@pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) -class TestUsesCustomEventLoopPolicy: - @pytest.mark.asyncio - async def test_uses_custom_event_loop_policy(self): - assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst deleted file mode 100644 index 6c3e5253..00000000 --- a/docs/source/reference/markers/index.rst +++ /dev/null @@ -1,65 +0,0 @@ -======= -Markers -======= - -``pytest.mark.asyncio`` -======================= -A coroutine or async generator with this marker will be treated as a test function by pytest. The marked function will be executed as an -asyncio task in the event loop provided by the ``event_loop`` fixture. - -In order to make your test code a little more concise, the pytest |pytestmark|_ -feature can be used to mark entire modules or classes with this marker. -Only test coroutines will be affected (by default, coroutines prefixed by -``test_``), so, for example, fixtures are safe to define. - -.. include:: pytestmark_asyncio_strict_mode_example.py - :code: python - -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added -automatically to *async* test functions. - - -``pytest.mark.asyncio_event_loop`` -================================== -Test classes or modules with this mark provide a class-scoped or module-scoped asyncio event loop. - -This functionality is orthogonal to the `asyncio` mark. -That means the presence of this mark does not imply that async test functions inside the class or module are collected by pytest-asyncio. -The collection happens automatically in `auto` mode. -However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions. - -The following code example uses the `asyncio_event_loop` mark to provide a shared event loop for all tests in `TestClassScopedLoop`: - -.. include:: class_scoped_loop_strict_mode_example.py - :code: python - -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: - -.. include:: class_scoped_loop_auto_mode_example.py - :code: python - -Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` mark to the module: - -.. include:: module_scoped_loop_auto_mode_example.py - :code: python - -The `asyncio_event_loop` mark supports an optional `policy` keyword argument to set the asyncio event loop policy. - -.. include:: class_scoped_loop_custom_policy_strict_mode_example.py - :code: python - - -The ``policy`` keyword argument may also take an iterable of event loop policies. This causes tests under by the `asyncio_event_loop` mark to be parametrized with different policies: - -.. include:: class_scoped_loop_custom_policies_strict_mode_example.py - :code: python - -If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``. - -Fixtures and tests sharing the same `asyncio_event_loop` mark are executed in the same event loop: - -.. include:: class_scoped_loop_with_fixture_strict_mode_example.py - :code: python - -.. |pytestmark| replace:: ``pytestmark`` -.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py b/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py deleted file mode 100644 index f1465728..00000000 --- a/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py +++ /dev/null @@ -1,11 +0,0 @@ -import asyncio - -import pytest - -# All test coroutines will be treated as marked. -pytestmark = pytest.mark.asyncio - - -async def test_example(): - """No marker!""" - await asyncio.sleep(0) diff --git a/docs/source/support.rst b/docs/support.rst similarity index 84% rename from docs/source/support.rst rename to docs/support.rst index 30981d94..f998bb35 100644 --- a/docs/source/support.rst +++ b/docs/support.rst @@ -18,4 +18,4 @@ If you require commercial support outside of the Tidelift subscription, reach ou Community support ================= -The GitHub page of pytest-asyncio offers free community support on a best-effort basis. Please use the `issue tracker `__ to report bugs and the `discussions `__ to ask questions. +The GitHub page of pytest-asyncio offers free community support on a best-effort basis. Please use the `issue tracker `__ to report bugs and the Matrix chat room `#pytest-asyncio:matrix.org `__ or `GitHub discussions `__ to ask questions. diff --git a/pyproject.toml b/pyproject.toml index 81540a53..46804245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,184 @@ [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", +] +maintainers = [ + { name = "Michael Seifert", email = "m.seifert@digitalernachschub.de" }, +] +authors = [ + { name = "Tin Tvrtković", email = "tinchester@gmail.com" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Testing", + "Typing :: Typed", +] +dynamic = [ + "version", +] + +dependencies = [ + "backports-asyncio-runner>=1.1,<2; python_version<'3.11'", + "pytest>=8.2,<9", + "typing-extensions>=4.12; python_version<'3.13'", +] +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" +local_scheme = "no-local-version" + +[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.pyproject-fmt] +max_supported_python = "3.14" + +[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", +] + +[tool.coverage.run] +source = [ + "pytest_asyncio", +] +branch = true +data_file = "coverage/coverage" +parallel = true + +[tool.coverage.report] +show_missing = true + +[tool.towncrier] +directory = "changelog.d" +filename = "docs/reference/changelog.rst" +title_format = "`{version} `_ - {project_date}" +issue_format = "`#{issue} `_" + +[[tool.towncrier.type]] +directory = "security" +name = "Security" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "downstream" +name = "Notes for Downstream Packagers" +showcontent = true diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index 1bc2811d..abd62e15 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,5 +1,11 @@ """The main point for importing pytest-asyncio items.""" -from ._version import version as __version__ # noqa -from .plugin import fixture -__all__ = ("fixture",) +from __future__ import annotations + +from importlib.metadata import version + +from .plugin import fixture, is_async_test + +__version__ = version(__name__) + +__all__ = ("fixture", "is_async_test") diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 18c86869..29252b3e 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,73 +1,79 @@ """pytest-asyncio implementation.""" + +from __future__ import annotations + import asyncio import contextlib +import contextvars import enum import functools import inspect import socket +import sys +import traceback import warnings -from textwrap import dedent -from typing import ( - Any, +from asyncio import AbstractEventLoop, AbstractEventLoopPolicy +from collections.abc import ( AsyncIterator, Awaitable, - Callable, - Dict, + Generator, Iterable, Iterator, - List, + Sequence, +) +from types import AsyncGeneratorType, CoroutineType +from typing import ( + Any, + Callable, Literal, - Optional, - Set, TypeVar, Union, overload, ) +import pluggy import pytest -from _pytest.mark.structures import get_unpacked_marks +from _pytest.fixtures import resolve_fixture_function +from _pytest.scope import Scope from pytest import ( - Collector, Config, + FixtureDef, FixtureRequest, Function, Item, - Metafunc, + Mark, + MonkeyPatch, Parser, PytestCollectionWarning, PytestDeprecationWarning, PytestPluginManager, - Session, - StashKey, ) -_R = TypeVar("_R") +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec -_ScopeName = Literal["session", "package", "module", "class", "function"] -_T = TypeVar("_T") +if sys.version_info >= (3, 11): + from asyncio import Runner +else: + from backports.asyncio.runner import Runner -SimpleFixtureFunction = TypeVar( - "SimpleFixtureFunction", bound=Callable[..., Awaitable[_R]] -) -FactoryFixtureFunction = TypeVar( - "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[_R]] -) -FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] -FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] +if sys.version_info >= (3, 13): + from typing import TypeIs +else: + from typing_extensions import TypeIs -# https://github.com/pytest-dev/pytest/pull/9510 -FixtureDef = Any -SubRequest = Any +_ScopeName = Literal["session", "package", "module", "class", "function"] +_R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) +_P = ParamSpec("_P") +FixtureFunction = Callable[_P, _R] class PytestAsyncioError(Exception): """Base class for exceptions raised by pytest-asyncio""" -class MultipleEventLoopsRequestedError(PytestAsyncioError): - """Raised when a test requests multiple asyncio event loops.""" - - class Mode(str, enum.Enum): AUTO = "auto" STRICT = "strict" @@ -90,59 +96,89 @@ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None metavar="MODE", help=ASYNCIO_MODE_HELP, ) + group.addoption( + "--asyncio-debug", + dest="asyncio_debug", + action="store_true", + default=None, + help="enable asyncio debug mode for the default event loop", + ) parser.addini( "asyncio_mode", help="default value for --asyncio-mode", default="strict", ) + parser.addini( + "asyncio_debug", + help="enable asyncio debug mode for the default event loop", + type="bool", + default="false", + ) + 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 @@ -152,15 +188,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: @@ -169,154 +206,119 @@ 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 + + +def _get_asyncio_debug(config: Config) -> bool: + val = config.getoption("asyncio_debug") + if val is None: + val = config.getini("asyncio_debug") + + if isinstance(val, bool): + return val + else: + return val == "true" + + +_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 _validate_scope(scope: str | None, option_name: str) -> None: + if scope is None: + return + valid_scopes = [s.value for s in Scope] + if scope not in valid_scopes: + raise pytest.UsageError( + f"{scope!r} is not a valid {option_name}. " + f"Valid scopes are: {', '.join(valid_scopes)}." ) def pytest_configure(config: Config) -> None: - """Inject documentation.""" + default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope") + _validate_scope(default_fixture_loop_scope, "asyncio_default_fixture_loop_scope") + if not default_fixture_loop_scope: + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) + + default_test_loop_scope = config.getini("asyncio_default_test_loop_scope") + _validate_scope(default_test_loop_scope, "asyncio_default_test_loop_scope") config.addinivalue_line( "markers", "asyncio: " "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) - config.addinivalue_line( - "markers", - "asyncio_event_loop: " - "Provides an asyncio event loop in the scope of the marked test " - "class or module", - ) @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}"] - - -def _preprocess_async_fixtures( - collector: Collector, - processed_fixturedefs: Set[FixtureDef], -) -> None: - config = collector.config - asyncio_mode = _get_asyncio_mode(config) - fixturemanager = config.pluginmanager.get_plugin("funcmanage") - event_loop_fixture_id = "event_loop" - for node, mark in collector.iter_markers_with_node("asyncio_event_loop"): - event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) - if event_loop_fixture_id: - break - for fixtures in fixturemanager._arg2fixturedefs.values(): - for fixturedef in fixtures: - func = fixturedef.func - if fixturedef in processed_fixturedefs or not _is_coroutine_or_asyncgen( - func - ): - continue - if not _is_asyncio_fixture_function(func) and asyncio_mode == Mode.STRICT: - # Ignore async fixtures without explicit asyncio mark in strict mode - # This applies to pytest_trio fixtures, for example - continue - _make_asyncio_fixture_function(func) - function_signature = inspect.signature(func) - if "event_loop" in function_signature.parameters: - warnings.warn( - PytestDeprecationWarning( - f"{func.__name__} is asynchronous and explicitly " - f'requests the "event_loop" fixture. Asynchronous fixtures and ' - f'test functions should use "asyncio.get_running_loop()" ' - f"instead." - ) - ) - _inject_fixture_argnames(fixturedef, event_loop_fixture_id) - _synchronize_async_fixture(fixturedef, event_loop_fixture_id) - 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. - """ + debug = _get_asyncio_debug(config) + 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"debug={debug}", + 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 _fixture_synchronizer( + fixturedef: FixtureDef, runner: Runner, request: FixtureRequest +) -> Callable: + """Returns a synchronous function evaluating the specified fixture.""" + fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) + return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef, event_loop_fixture_id) - - -def _add_kwargs( - func: Callable[..., Any], - kwargs: Dict[str, Any], - event_loop_fixture_id: str, - event_loop: asyncio.AbstractEventLoop, - request: SubRequest, -) -> 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 - return ret - - -def _perhaps_rebind_fixture_func( - func: _T, instance: Optional[Any], unittest: bool -) -> _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. - unbound, cls = func, None - try: - 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 - # the same type. - if unittest or (cls is not None and isinstance(instance, cls)): - func = unbound.__get__(instance) # type: ignore - return func + return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] + else: + return fixturedef.func -def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: - fixture = fixturedef.func +AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") +AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") - @functools.wraps(fixture) - def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): - func = _perhaps_rebind_fixture_func( - fixture, request.instance, fixturedef.unittest - ) - event_loop = kwargs.pop(event_loop_fixture_id) - gen_obj = func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + +def _wrap_asyncgen_fixture( + fixture_function: Callable[ + AsyncGenFixtureParams, AsyncGeneratorType[AsyncGenFixtureYieldType, Any] + ], + runner: Runner, + request: FixtureRequest, +) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: + @functools.wraps(fixture_function) + def _asyncgen_fixture_wrapper( + *args: AsyncGenFixtureParams.args, + **kwargs: AsyncGenFixtureParams.kwargs, + ): + gen_obj = fixture_function(*args, **kwargs) async def setup(): res = await gen_obj.__anext__() return res + context = contextvars.copy_context() + result = runner.run(setup(), context=context) + + reset_contextvars = _apply_contextvar_changes(context) + def finalizer() -> None: """Yield again, to finalize.""" @@ -330,52 +332,103 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - event_loop.run_until_complete(async_finalizer()) + runner.run(async_finalizer(), context=context) + 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 + return _asyncgen_fixture_wrapper -def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: - fixture = fixturedef.func +AsyncFixtureParams = ParamSpec("AsyncFixtureParams") +AsyncFixtureReturnType = TypeVar("AsyncFixtureReturnType") - @functools.wraps(fixture) - def _async_fixture_wrapper(request: SubRequest, **kwargs: Any): - func = _perhaps_rebind_fixture_func( - fixture, request.instance, fixturedef.unittest - ) - event_loop = kwargs.pop(event_loop_fixture_id) +def _wrap_async_fixture( + fixture_function: Callable[ + AsyncFixtureParams, CoroutineType[Any, Any, AsyncFixtureReturnType] + ], + runner: Runner, + request: FixtureRequest, +) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: + @functools.wraps(fixture_function) + def _async_fixture_wrapper( + *args: AsyncFixtureParams.args, + **kwargs: AsyncFixtureParams.kwargs, + ): async def setup(): - res = await func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + res = await fixture_function(*args, **kwargs) return res - return event_loop.run_until_complete(setup()) + context = contextvars.copy_context() + result = runner.run(setup(), context=context) + + # 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 + + return _async_fixture_wrapper - fixturedef.func = _async_fixture_wrapper + +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) + + return restore_contextvars class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" @classmethod - def substitute(cls, item: Function, /) -> Function: + def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | None: """ - Returns a PytestAsyncioFunction if there is an implementation that can handle - the specified function item. + Returns a subclass of PytestAsyncioFunction if there is a specialized subclass + for the specified function item. - If no implementation of PytestAsyncioFunction can handle the specified item, - the item is returned unchanged. + Return None if no specialized subclass exists for the specified item. """ for subclass in cls.__subclasses__(): if subclass._can_substitute(item): - return subclass._from_function(item) - return item + return subclass + return None @classmethod def _from_function(cls, function: Function, /) -> Function: @@ -383,6 +436,8 @@ def _from_function(cls, function: Function, /) -> Function: Instantiates this specific PytestAsyncioFunction type from the specified Function item. """ + assert function.get_closest_marker("asyncio") + assert function.parent is not None subclass_instance = cls.from_parent( function.parent, name=function.name, @@ -392,15 +447,8 @@ def _from_function(cls, function: Function, /) -> Function: keywords=function.keywords, originalname=function.originalname, ) - subclassed_function_signature = inspect.signature(subclass_instance.obj) - if "event_loop" in subclassed_function_signature.parameters: - subclass_instance.warn( - PytestDeprecationWarning( - f"{subclass_instance.name} is asynchronous and explicitly " - f'requests the "event_loop" fixture. Asynchronous fixtures and ' - f'test functions should use "asyncio.get_running_loop()" instead.' - ) - ) + subclass_instance.own_markers = function.own_markers + assert subclass_instance.own_markers == function.own_markers return subclass_instance @staticmethod @@ -408,6 +456,49 @@ def _can_substitute(item: Function) -> bool: """Returns whether the specified function can be replaced by this class""" raise NotImplementedError() + def setup(self) -> None: + runner_fixture_id = f"_{self._loop_scope}_scoped_runner" + if runner_fixture_id not in self.fixturenames: + self.fixturenames.append(runner_fixture_id) + return super().setup() + + def runtest(self) -> None: + runner_fixture_id = f"_{self._loop_scope}_scoped_runner" + runner = self._request.getfixturevalue(runner_fixture_id) + context = contextvars.copy_context() + synchronized_obj = _synchronize_coroutine( + getattr(*self._synchronization_target_attr), runner, context + ) + with MonkeyPatch.context() as c: + c.setattr(*self._synchronization_target_attr, synchronized_obj) + super().runtest() + + @functools.cached_property + def _loop_scope(self) -> _ScopeName: + """ + Return the scope of the asyncio event loop this item is run in. + + The effective scope is determined lazily. It is identical to to the + `loop_scope` value of the closest `asyncio` pytest marker. If no such + marker is present, the the loop scope is determined by the configuration + value of `asyncio_default_test_loop_scope`, instead. + """ + marker = self.get_closest_marker("asyncio") + assert marker is not None + default_loop_scope = _get_default_test_loop_scope(self.config) + return _get_marked_loop_scope(marker, default_loop_scope) + + @property + def _synchronization_target_attr(self) -> tuple[object, str]: + """ + Return the coroutine that needs to be synchronized during the test run. + + This method is inteded to be overwritten by subclasses when they need to apply + the coroutine synchronizer to a value that's different from self.obj + e.g. the AsyncHypothesisTest subclass. + """ + return self, "obj" + class Coroutine(PytestAsyncioFunction): """Pytest item created by a coroutine""" @@ -415,15 +506,7 @@ class Coroutine(PytestAsyncioFunction): @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj - return asyncio.iscoroutinefunction(func) - - def runtest(self) -> None: - if self.get_closest_marker("asyncio"): - self.obj = wrap_in_sync( - # https://github.com/pytest-dev/pytest-asyncio/issues/596 - self.obj, # type: ignore[has-type] - ) - super().runtest() + return inspect.iscoroutinefunction(func) class AsyncGenerator(PytestAsyncioFunction): @@ -461,14 +544,6 @@ def _can_substitute(item: Function) -> bool: func.__func__ ) - def runtest(self) -> None: - if self.get_closest_marker("asyncio"): - self.obj = wrap_in_sync( - # https://github.com/pytest-dev/pytest-asyncio/issues/596 - self.obj, # type: ignore[has-type] - ) - super().runtest() - class AsyncHypothesisTest(PytestAsyncioFunction): """ @@ -476,302 +551,146 @@ class AsyncHypothesisTest(PytestAsyncioFunction): @hypothesis.given. """ + def setup(self) -> None: + if not getattr(self.obj, "hypothesis", False) and getattr( + self.obj, "is_hypothesis_test", False + ): + pytest.fail( + f"test function `{self!r}` is using Hypothesis, but pytest-asyncio " + "only works with Hypothesis 3.64.0 or later." + ) + return super().setup() + @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj - return getattr( - func, "is_hypothesis_test", False - ) and asyncio.iscoroutinefunction(func.hypothesis.inner_test) - - def runtest(self) -> None: - if self.get_closest_marker("asyncio"): - self.obj.hypothesis.inner_test = wrap_in_sync( - self.obj.hypothesis.inner_test, - ) - super().runtest() - + return ( + getattr(func, "is_hypothesis_test", False) # type: ignore[return-value] + and getattr(func, "hypothesis", None) + and inspect.iscoroutinefunction(func.hypothesis.inner_test) + ) -_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 -]: - """A pytest hook to collect asyncio coroutines.""" - if not collector.funcnamefilter(name): - return None - _preprocess_async_fixtures(collector, _HOLDER) - return None + @property + def _synchronization_target_attr(self) -> tuple[object, str]: + return self.obj.hypothesis, "inner_test" # 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 -) -> Union[ - pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], 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 = 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 - try: + if isinstance(node_or_list_of_nodes, Sequence): node_iterator = iter(node_or_list_of_nodes) - except TypeError: + else: # Treat single node as a single-element iterable node_iterator = iter((node_or_list_of_nodes,)) updated_node_collection = [] for node in node_iterator: updated_item = node if isinstance(node, Function): - updated_item = PytestAsyncioFunction.substitute(node) + specialized_item_class = PytestAsyncioFunction.item_subclass_for(node) + if specialized_item_class: + if _get_asyncio_mode( + node.config + ) == Mode.AUTO and not node.get_closest_marker("asyncio"): + node.add_marker("asyncio") + if node.get_closest_marker("asyncio"): + updated_item = specialized_item_class._from_function(node) updated_node_collection.append(updated_item) - hook_result.force_result(updated_node_collection) -_event_loop_fixture_id = StashKey[str] - - -@pytest.hookimpl -def pytest_collectstart(collector: pytest.Collector): - if not isinstance(collector, (pytest.Class, pytest.Module)): - return - # pytest.Collector.own_markers is empty at this point, - # so we rely on _pytest.mark.structures.get_unpacked_marks - marks = get_unpacked_marks(collector.obj, consider_mro=True) - for mark in marks: - if not mark.name == "asyncio_event_loop": - continue - event_loop_policy = mark.kwargs.get("policy", asyncio.get_event_loop_policy()) - policy_params = ( - event_loop_policy - if isinstance(event_loop_policy, Iterable) - else (event_loop_policy,) - ) - - # There seem to be issues when a fixture is shadowed by another fixture - # and both differ in their params. - # https://github.com/pytest-dev/pytest/issues/2043 - # https://github.com/pytest-dev/pytest/issues/11350 - # As such, we assign a unique name for each event_loop fixture. - # The fixture name is stored in the collector's Stash, so it can - # be injected when setting up the test - event_loop_fixture_id = f"{collector.nodeid}::" - collector.stash[_event_loop_fixture_id] = event_loop_fixture_id - - @pytest.fixture( - scope="class" if isinstance(collector, pytest.Class) else "module", - name=event_loop_fixture_id, - params=policy_params, - ids=tuple(type(policy).__name__ for policy in policy_params), - ) - def scoped_event_loop( - *args, # Function needs to accept "cls" when collected by pytest.Class - request, - ) -> Iterator[asyncio.AbstractEventLoop]: - new_loop_policy = request.param - old_loop_policy = asyncio.get_event_loop_policy() - old_loop = asyncio.get_event_loop() - asyncio.set_event_loop_policy(new_loop_policy) - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - yield loop - loop.close() - asyncio.set_event_loop_policy(old_loop_policy) - asyncio.set_event_loop(old_loop) - - # @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 - # collected Python class, where it will be picked up by pytest.Class.collect() - # or pytest.Module.collect(), respectively - collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop - break - - -def pytest_collection_modifyitems( - session: Session, config: Config, items: List[Item] -) -> None: - """ - Marks collected async test items as `asyncio` tests. - - The mark is only applied in `AUTO` mode. It is applied to: - - - coroutines and async generators - - Hypothesis tests wrapping coroutines - - staticmethods wrapping coroutines - - """ - if _get_asyncio_mode(config) != Mode.AUTO: - return - for item in items: - if isinstance(item, PytestAsyncioFunction): - item.add_marker("asyncio") - - -_REDEFINED_EVENT_LOOP_FIXTURE_WARNING = dedent( - """\ - The event_loop fixture provided by pytest-asyncio has been redefined in - %s:%d - 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 class or module scope, - please attach the asyncio_event_loop mark to the respective class or module. - """ -) - - -@pytest.hookimpl(tryfirst=True) -def pytest_generate_tests(metafunc: Metafunc) -> None: - for event_loop_provider_node, _ in metafunc.definition.iter_markers_with_node( - "asyncio_event_loop" - ): - event_loop_fixture_id = event_loop_provider_node.stash.get( - _event_loop_fixture_id, None - ) - if event_loop_fixture_id: - # This specific fixture name may already be in metafunc.argnames, if this - # test indirectly depends on the fixture. For example, this is the case - # when the test depends on an async fixture, both of which share the same - # asyncio_event_loop mark. - if event_loop_fixture_id in metafunc.fixturenames: - continue - fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") - if "event_loop" in metafunc.fixturenames: - raise MultipleEventLoopsRequestedError( - _MULTIPLE_LOOPS_REQUESTED_ERROR - % (metafunc.definition.nodeid, event_loop_provider_node.nodeid), - ) - # Add the scoped event loop fixture to Metafunc's list of fixture names and - # fixturedefs and leave the actual parametrization to pytest - metafunc.fixturenames.insert(0, event_loop_fixture_id) - metafunc._arg2fixturedefs[ - event_loop_fixture_id - ] = fixturemanager._arg2fixturedefs[event_loop_fixture_id] - break - - -@pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup( - fixturedef: FixtureDef, request: SubRequest -) -> Optional[object]: - """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 - # pytest_fixture_post_finalizer hook. The fixture finalizer is invoked once - # for each fixture, whereas the hook may be invoked multiple times for - # any specific fixture. - # see https://github.com/pytest-dev/pytest/issues/5848 - _add_finalizers( - fixturedef, - _close_event_loop, - _provide_clean_event_loop, - ) - outcome = yield - loop = outcome.get_result() - # 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): - _, fixture_line_number = inspect.getsourcelines(fixturedef.func) - warnings.warn( - _REDEFINED_EVENT_LOOP_FIXTURE_WARNING - % (fixture_filename, fixture_line_number), - DeprecationWarning, - ) - policy = asyncio.get_event_loop_policy() - try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - old_loop = policy.get_event_loop() - if old_loop is not loop: - old_loop.close() - except RuntimeError: - # Either the current event loop has been set to None - # or the loop policy doesn't specify to create new loops - # or we're not in the main thread - pass - policy.set_event_loop(loop) - return - - yield +@contextlib.contextmanager +def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: + old_loop_policy = _get_event_loop_policy() + try: + old_loop = _get_event_loop_no_warn() + except RuntimeError: + old_loop = None + _set_event_loop_policy(policy) + try: + yield + finally: + _set_event_loop_policy(old_loop_policy) + _set_event_loop(old_loop) -def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None: - """ - Regsiters the specified fixture finalizers in the fixture. +def _get_event_loop_policy() -> AbstractEventLoopPolicy: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return asyncio.get_event_loop_policy() - Finalizers need to 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 - """ - for finalizer in reversed(finalizers): - fixturedef.addfinalizer(finalizer) - - -_UNCLOSED_EVENT_LOOP_WARNING = dedent( - """\ - pytest-asyncio detected an unclosed event loop when tearing down the event_loop - fixture: %r - pytest-asyncio will close the event loop for you, but future versions of the - library will no longer do so. In order to ensure compatibility with future - versions, please make sure that: - 1. Any custom "event_loop" fixture properly closes the loop after yielding it - 2. The scopes of your custom "event_loop" fixtures do not overlap - 3. Your code does not modify the event loop in async fixtures or tests - """ -) +def _set_event_loop_policy(policy: AbstractEventLoopPolicy) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + asyncio.set_event_loop_policy(policy) -def _close_event_loop() -> None: - policy = asyncio.get_event_loop_policy() - try: - loop = policy.get_event_loop() - except RuntimeError: - loop = None - if loop is not None: - if not loop.is_closed(): - warnings.warn( - _UNCLOSED_EVENT_LOOP_WARNING % loop, - DeprecationWarning, - ) - loop.close() +def _get_event_loop_no_warn( + policy: AbstractEventLoopPolicy | None = None, +) -> asyncio.AbstractEventLoop: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + if policy is not None: + return policy.get_event_loop() + else: + return asyncio.get_event_loop() -def _provide_clean_event_loop() -> None: - # At this point, the event loop for the current thread is closed. - # When a user calls asyncio.get_event_loop(), they will get a closed loop. - # In order to avoid this side effect from pytest-asyncio, we need to replace - # the current loop with a fresh one. - # 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) +def _set_event_loop(loop: AbstractEventLoop | None) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + asyncio.set_event_loop(loop) @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: - """ - Pytest hook called before a test case is run. - - Wraps marked tests in a synchronous function - where the wrapped test coroutine is executed in an event loop. - """ +def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: + """Pytest hook called before a test case is run.""" if pyfuncitem.get_closest_marker("asyncio") is not None: - if isinstance(pyfuncitem, PytestAsyncioFunction): - pass + if is_async_test(pyfuncitem): + 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 " + "pytest-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( @@ -783,88 +702,148 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> Optional[object]: ) ) yield + return None -def wrap_in_sync( - func: Callable[..., Awaitable[Any]], +def _synchronize_coroutine( + func: Callable[..., CoroutineType], + runner: asyncio.Runner, + context: contextvars.Context, ): - """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 - raw_func = getattr(func, "_raw_test_func", None) - if raw_func is not None: - func = raw_func + """ + Return a sync wrapper around a coroutine executing it in the + specified runner and context. + """ @functools.wraps(func) def inner(*args, **kwargs): coro = func(*args, **kwargs) - _loop = asyncio.get_event_loop() - task = asyncio.ensure_future(coro, loop=_loop) - try: - _loop.run_until_complete(task) - except BaseException: - # run_until_complete doesn't get the result from exceptions - # that are not subclasses of `Exception`. Consume all - # exceptions to prevent asyncio's warning from logging. - if task.done() and not task.cancelled(): - task.exception() - raise - - inner._raw_test_func = func # type: ignore[attr-defined] + runner.run(coro, context=context) + return inner -_MULTIPLE_LOOPS_REQUESTED_ERROR = dedent( - """\ - Multiple asyncio event loops with different scopes have been requested - by %s. The test explicitly requests the event_loop fixture, while another - event loop is provided by %s. - Remove "event_loop" from the requested fixture in your test to run the test - in a larger-scoped event loop or remove the "asyncio_event_loop" mark to run - the test in a function-scoped event loop. - """ -) +@pytest.hookimpl(wrapper=True) +def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: + asyncio_mode = _get_asyncio_mode(request.config) + if not _is_asyncio_fixture_function(fixturedef.func): + if asyncio_mode == Mode.STRICT: + # Ignore async fixtures without explicit asyncio mark in strict mode + # This applies to pytest_trio fixtures, for example + return (yield) + if not _is_coroutine_or_asyncgen(fixturedef.func): + return (yield) + default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope") + loop_scope = ( + getattr(fixturedef.func, "_loop_scope", None) + or default_loop_scope + or fixturedef.scope + ) + runner_fixture_id = f"_{loop_scope}_scoped_runner" + runner = request.getfixturevalue(runner_fixture_id) + synchronizer = _fixture_synchronizer(fixturedef, runner, request) + _make_asyncio_fixture_function(synchronizer, loop_scope) + with MonkeyPatch.context() as c: + c.setattr(fixturedef, "func", synchronizer) + hook_result = yield + return hook_result + + +_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 pytest_runtest_setup(item: pytest.Item) -> None: - marker = item.get_closest_marker("asyncio") - if marker is None: - return - event_loop_fixture_id = "event_loop" - for node, mark in item.iter_markers_with_node("asyncio_event_loop"): - event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) - if event_loop_fixture_id: - break - fixturenames = item.fixturenames # type: ignore[attr-defined] - # inject an event loop fixture for all async tests - if "event_loop" in fixturenames: - fixturenames.remove("event_loop") - fixturenames.insert(0, event_loop_fixture_id) - obj = getattr(item, "obj", None) - if not getattr(obj, "hypothesis", False) and getattr( - obj, "is_hypothesis_test", False + +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"} ): - pytest.fail( - "test function `%r` is using Hypothesis, but pytest-asyncio " - "only works with Hypothesis 3.64.0 or later." % item - ) + 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 -@pytest.fixture -def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: - """Create an instance of the default event loop for each test case.""" - 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() +def _get_default_test_loop_scope(config: Config) -> Any: + return config.getini("asyncio_default_test_loop_scope") + + +_RUNNER_TEARDOWN_WARNING = """\ +An exception occurred during teardown of an asyncio.Runner. \ +The reason is likely that you closed the underlying event loop in a test, \ +which prevents the cleanup of asynchronous generators by the runner. +This warning will become an error in future versions of pytest-asyncio. \ +Please ensure that your tests don't close the event loop. \ +Here is the traceback of the exception triggered during teardown: +%s +""" + + +def _create_scoped_runner_fixture(scope: _ScopeName) -> Callable: + @pytest.fixture( + scope=scope, + name=f"_{scope}_scoped_runner", + ) + def _scoped_runner( + event_loop_policy, + request: FixtureRequest, + ) -> Iterator[Runner]: + new_loop_policy = event_loop_policy + debug_mode = _get_asyncio_debug(request.config) + with _temporary_event_loop_policy(new_loop_policy): + runner = Runner(debug=debug_mode).__enter__() + try: + yield runner + except Exception as e: + runner.__exit__(type(e), e, e.__traceback__) + else: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", ".*BaseEventLoop.shutdown_asyncgens.*", RuntimeWarning + ) + try: + runner.__exit__(None, None, None) + except RuntimeError: + warnings.warn( + _RUNNER_TEARDOWN_WARNING % traceback.format_exc(), + RuntimeWarning, + ) + + return _scoped_runner + + +for scope in Scope: + globals()[f"_{scope.value}_scoped_runner"] = _create_scoped_runner_fixture( + scope.value + ) + + +@pytest.fixture(scope="session", autouse=True) +def event_loop_policy() -> AbstractEventLoopPolicy: + """Return an instance of the policy used to create asyncio event loops.""" + return _get_event_loop_policy() + + +def is_async_test(item: Item) -> TypeIs[PytestAsyncioFunction]: + """Returns whether a test item is a pytest-asyncio test""" + return isinstance(item, PytestAsyncioFunction) def _unused_port(socket_type: int) -> int: diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d027a942..00000000 --- a/setup.cfg +++ /dev/null @@ -1,78 +0,0 @@ -[metadata] -name = pytest-asyncio -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 - - Topic :: Software Development :: Testing - - Framework :: AsyncIO - Framework :: Pytest - Typing :: Typed - -[options] -python_requires = >=3.8 -packages = find: -include_package_data = True - -# Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies -install_requires = - pytest >= 7.0.0 - -[options.extras_require] -testing = - coverage >= 6.2 - hypothesis >= 5.7.1 - flaky >= 3.5.0 - mypy >= 0.931 - pytest-trio >= 0.7.0 -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/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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..e8634d0c --- /dev/null +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -0,0 +1,270 @@ +""" +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 + +from textwrap import dedent +from typing import Literal + +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) + + +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) + + +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) + + +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) + + +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) + + +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) + + +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) + + +def test_no_isolation_against_context_changes_in_sync_tests(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """ + import pytest + import pytest_asyncio + from contextvars import ContextVar + + _context_var = ContextVar("my_var") + + def test_sync(): + _context_var.set("new_value") + + @pytest.mark.asyncio + async def test_async(): + assert _context_var.get() == "new_value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +@pytest.mark.parametrize("loop_scope", ("function", "module")) +def test_isolation_against_context_changes_in_async_tests( + pytester: Pytester, loop_scope: Literal["function", "module"] +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + f""" + import pytest + import pytest_asyncio + from contextvars import ContextVar + + _context_var = ContextVar("my_var") + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_async_first(): + _context_var.set("new_value") + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_async_second(): + with pytest.raises(LookupError): + _context_var.get() + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) diff --git a/tests/async_fixtures/test_async_fixtures_scope.py b/tests/async_fixtures/test_async_fixtures_scope.py deleted file mode 100644 index 079a981a..00000000 --- a/tests/async_fixtures/test_async_fixtures_scope.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -We support module-scoped async fixtures, but only if the event loop is -module-scoped too. -""" -import asyncio - -import pytest - - -@pytest.fixture(scope="module") -def event_loop(): - """A module-scoped event loop.""" - loop = asyncio.new_event_loop() - yield loop - loop.close() - - -@pytest.fixture(scope="module") -async def async_fixture(): - await asyncio.sleep(0.1) - return 1 - - -@pytest.mark.asyncio -async def test_async_fixture_scope(async_fixture): - assert async_fixture == 1 - await asyncio.sleep(0.1) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py deleted file mode 100644 index aa2ce3d7..00000000 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ /dev/null @@ -1,59 +0,0 @@ -import asyncio -import functools - -import pytest - - -@pytest.mark.asyncio -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 -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 - - -@pytest.fixture(scope="module") -def event_loop(): - """Change event_loop fixture to module level.""" - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - - -@pytest.fixture(scope="module") -async def port_with_event_loop_finalizer(request): - def port_finalizer(finalizer): - async def port_afinalizer(): - # await task using loop provided by event_loop fixture - # RuntimeError is raised if task is created on a different loop - await finalizer - - asyncio.get_event_loop().run_until_complete(port_afinalizer()) - - worker = asyncio.ensure_future(asyncio.sleep(0.2)) - request.addfinalizer(functools.partial(port_finalizer, worker)) - return True - - -@pytest.fixture(scope="module") -async def port_with_get_event_loop_finalizer(request): - def port_finalizer(finalizer): - async def port_afinalizer(): - # await task using current loop retrieved from the event loop policy - # RuntimeError is raised if task is created on a different loop. - # This can happen when pytest_fixture_setup - # does not set up the loop correctly, - # for example when policy.set_event_loop() is called with a wrong argument - await finalizer - - current_loop = asyncio.get_event_loop_policy().get_event_loop() - current_loop.run_until_complete(port_afinalizer()) - - worker = asyncio.ensure_future(asyncio.sleep(0.2)) - request.addfinalizer(functools.partial(port_finalizer, worker)) - return True diff --git a/tests/async_fixtures/test_async_gen_fixtures.py b/tests/async_fixtures/test_async_gen_fixtures.py deleted file mode 100644 index 2b198f2b..00000000 --- a/tests/async_fixtures/test_async_gen_fixtures.py +++ /dev/null @@ -1,51 +0,0 @@ -import unittest.mock - -import pytest - -START = object() -END = object() -RETVAL = object() - - -@pytest.fixture(scope="module") -def mock(): - return unittest.mock.Mock(return_value=RETVAL) - - -@pytest.fixture -async def async_gen_fixture(mock): - try: - yield mock(START) - except Exception as e: - mock(e) - else: - mock(END) - - -@pytest.mark.asyncio -async def test_async_gen_fixture(async_gen_fixture, mock): - assert mock.called - assert mock.call_args_list[-1] == unittest.mock.call(START) - assert async_gen_fixture is RETVAL - - -@pytest.mark.asyncio -async def test_async_gen_fixture_finalized(mock): - try: - assert mock.called - assert mock.call_args_list[-1] == unittest.mock.call(END) - finally: - mock.reset_mock() - - -class TestAsyncGenFixtureMethod: - is_same_instance = False - - @pytest.fixture(autouse=True) - async def async_gen_fixture_method(self): - self.is_same_instance = True - yield None - - @pytest.mark.asyncio - async def test_async_gen_fixture_method(self): - assert self.is_same_instance 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 deleted file mode 100644 index 2bdbe5e8..00000000 --- a/tests/async_fixtures/test_parametrized_loop.py +++ /dev/null @@ -1,46 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_event_loop_parametrization(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - import pytest_asyncio - - TESTS_COUNT = 0 - - - def teardown_module(): - # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' - assert TESTS_COUNT == 4 - - - @pytest.fixture(scope="module", params=[1, 2]) - def event_loop(request): - request.param - loop = asyncio.new_event_loop() - yield loop - loop.close() - - - @pytest_asyncio.fixture(params=["a", "b"]) - async def fix(request): - await asyncio.sleep(0) - return request.param - - - @pytest.mark.asyncio - async def test_parametrized_loop(fix): - await asyncio.sleep(0) - global TESTS_COUNT - TESTS_COUNT += 1 - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=4) 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..eecab735 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,3 @@ -import asyncio - -import pytest +from __future__ import annotations pytest_plugins = "pytester" - - -@pytest.fixture -def dependent_fixture(event_loop): - """A fixture dependent on the event_loop fixture, doing some cleanup.""" - counter = 0 - - async def just_a_sleep(): - """Just sleep a little while.""" - nonlocal event_loop - await asyncio.sleep(0.1) - nonlocal counter - counter += 1 - - event_loop.run_until_complete(just_a_sleep()) - yield - event_loop.run_until_complete(just_a_sleep()) - - assert counter == 2 - - -@pytest.fixture(scope="session", name="factory_involving_factories") -def factory_involving_factories_fixture(unused_tcp_port_factory): - def factory(): - return unused_tcp_port_factory() - - return factory diff --git a/tests/hypothesis/__init__.py b/tests/hypothesis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index aef20d79..487b05fe 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -1,6 +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 @@ -8,10 +12,23 @@ from pytest import Pytester -@given(st.integers()) -@pytest.mark.asyncio -async def test_mark_inner(n): - assert isinstance(n, int) +def test_hypothesis_given_decorator_before_asyncio_mark(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + from hypothesis import given, strategies as st + + @given(st.integers()) + @pytest.mark.asyncio + async def test_mark_inner(n): + assert isinstance(n, int) + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1) @pytest.mark.asyncio @@ -28,37 +45,8 @@ async def test_mark_and_parametrize(x, y): assert y in (1, 2) -def test_can_use_explicit_event_loop_fixture(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - from hypothesis import given - import hypothesis.strategies as st - - pytest_plugins = 'pytest_asyncio' - - @pytest.fixture(scope="module") - def event_loop(): - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - @given(st.integers()) - @pytest.mark.asyncio - async def test_explicit_fixture_request(event_loop, n): - semaphore = asyncio.Semaphore(value=0) - event_loop.call_soon(semaphore.release) - await semaphore.acquire() - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - def test_async_auto_marked(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -81,6 +69,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 deleted file mode 100644 index 6b9a7649..00000000 --- a/tests/loop_fixture_scope/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -import asyncio - -import pytest - - -class CustomSelectorLoop(asyncio.SelectorEventLoop): - """A subclass with no overrides, just to test for presence.""" - - -@pytest.fixture(scope="module") -def event_loop(): - """Create an instance of the default event loop for each test case.""" - loop = CustomSelectorLoop() - yield loop - loop.close() diff --git a/tests/loop_fixture_scope/test_loop_fixture_scope.py b/tests/loop_fixture_scope/test_loop_fixture_scope.py deleted file mode 100644 index 679ab48f..00000000 --- a/tests/loop_fixture_scope/test_loop_fixture_scope.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Unit tests for overriding the event loop with a larger scoped one.""" -import asyncio - -import pytest - - -@pytest.mark.asyncio -async def test_for_custom_loop(): - """This test should be executed using the custom loop.""" - await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" - - -@pytest.mark.asyncio -async def test_dependent_fixture(dependent_fixture): - await asyncio.sleep(0.1) diff --git a/tests/markers/__init__.py b/tests/markers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_scope.py similarity index 51% rename from tests/markers/test_class_marker.py rename to tests/markers/test_class_scope.py index f8cf4ca0..e8732e86 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_scope.py @@ -1,4 +1,7 @@ """Test if pytestmark works when defined on a class.""" + +from __future__ import annotations + import asyncio from textwrap import dedent @@ -26,24 +29,24 @@ def sample_fixture(): return None -def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode( +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( """\ import asyncio import pytest - @pytest.mark.asyncio_event_loop class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") async def test_remember_loop(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop """ @@ -53,16 +56,17 @@ async def test_this_runs_in_same_loop(self): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode( +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_event_loop + @pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -74,29 +78,28 @@ async def test_this_runs_in_same_loop(self): """ ) ) - result = pytester.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): +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_event_loop + @pytest.mark.asyncio(loop_scope="class") class TestSuperClassWithMark: pass class TestWithoutMark(TestSuperClassWithMark): loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio async def test_remember_loop(self): TestWithoutMark.loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestWithoutMark.loop """ @@ -106,31 +109,10 @@ async def test_this_runs_in_same_loop(self): result.assert_outcomes(passed=2) -def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( - pytester: pytest.Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.mark.asyncio_event_loop - class TestClassScopedLoop: - @pytest.mark.asyncio - async def test_remember_loop(self, event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") - - -def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( +def test_asyncio_mark_respects_the_loop_policy( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -140,8 +122,10 @@ def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): pass - @pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) - class TestUsesCustomEventLoopPolicy: + class TestUsesCustomEventLoop: + @pytest.fixture(scope="class") + def event_loop_policy(self): + return CustomEventLoopPolicy() @pytest.mark.asyncio async def test_uses_custom_event_loop_policy(self): @@ -163,9 +147,10 @@ async def test_does_not_use_custom_event_loop_policy(): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( +def test_asyncio_mark_respects_parametrized_loop_policies( pytester: pytest.Pytester, ): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makepyfile( dedent( """\ @@ -173,15 +158,19 @@ def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( import pytest - @pytest.mark.asyncio_event_loop( - policy=[ + @pytest.fixture( + scope="class", + params=[ asyncio.DefaultEventLoopPolicy(), asyncio.DefaultEventLoopPolicy(), ] ) + def event_loop_policy(request): + return request.param + + @pytest.mark.asyncio(loop_scope="class") class TestWithDifferentLoopPolicies: - @pytest.mark.asyncio - async def test_parametrized_loop(self): + async def test_parametrized_loop(self, request): pass """ ) @@ -190,9 +179,10 @@ async def test_parametrized_loop(self): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( +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( """\ @@ -201,7 +191,7 @@ def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( import pytest import pytest_asyncio - @pytest.mark.asyncio_event_loop + @pytest.mark.asyncio(loop_scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -217,3 +207,91 @@ async def test_runs_is_same_loop_as_fixture(self, my_fixture): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + class TestMixedScopes: + @pytest_asyncio.fixture(loop_scope="class", scope="class") + async def async_fixture(self): + global loop + loop = asyncio.get_running_loop() + + @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 + + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import asyncio + + class TestClass: + @pytest.fixture(scope="class") + def sets_event_loop_to_none(self): + # asyncio.run() creates a new event loop without closing the + # existing one. For any test, but the first one, this leads to + # a ResourceWarning when the discarded loop is destroyed by the + # garbage collector. We close the current loop to avoid this. + try: + asyncio.get_event_loop().close() + except RuntimeError: + pass + return asyncio.run(asyncio.sleep(0)) + # asyncio.run() sets the current event loop to None when finished + + @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): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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(loop_scope="class") + class TestClass: + async def test_anything(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py new file mode 100644 index 00000000..16d45da5 --- /dev/null +++ b/tests/markers/test_function_scope.py @@ -0,0 +1,278 @@ +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( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio + + 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_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("--asyncio-mode=strict", "--assert=plain") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines("*DeprecationWarning*") + + +def test_asyncio_mark_respects_the_loop_policy( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + @pytest.fixture(scope="function") + def event_loop_policy(): + return CustomEventLoopPolicy() + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_respects_parametrized_loop_policies( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + @pytest.fixture( + scope="module", + params=[ + CustomEventLoopPolicy(), + CustomEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_function_scoped_loop_to_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + pytestmark = pytest.mark.asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + async def my_fixture(): + global loop + loop = asyncio.get_running_loop() + + async def test_runs_is_same_loop_as_fixture(my_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import asyncio + + @pytest.fixture + def sets_event_loop_to_none(): + # asyncio.run() creates a new event loop without closing the existing + # one. For any test, but the first one, this leads to a ResourceWarning + # when the discarded loop is destroyed by the garbage collector. + # We close the current loop to avoid this + try: + asyncio.get_event_loop().close() + except RuntimeError: + pass + return asyncio.run(asyncio.sleep(0)) + # asyncio.run() sets the current event loop to None when finished + + @pytest.mark.asyncio + # parametrization may impact fixture ordering + @pytest.mark.parametrize("n", (0, 1)) + async def test_does_not_fail(sets_event_loop_to_none, n): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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 + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict", "--assert=plain") + result.assert_outcomes(warnings=0, passed=1) + + +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( + """\ + def pytest_configure(config): + config.addinivalue_line( + "markers", "dummy_marker: mark used for testing purposes" + ) + """ + ) + ) + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.dummy_marker + async def test_markers_not_duplicated(request): + markers = [] + for node, marker in request.node.iter_markers_with_node(): + markers.append(marker) + assert len(markers) == 2 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto", "--assert=plain") + result.assert_outcomes(warnings=0, passed=1) 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_marker.py b/tests/markers/test_module_marker.py deleted file mode 100644 index f6cd8762..00000000 --- a/tests/markers/test_module_marker.py +++ /dev/null @@ -1,245 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_asyncio_mark_works_on_module_level(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - - pytestmark = pytest.mark.asyncio - - - class TestPyTestMark: - async def test_is_asyncio(self, event_loop, sample_fixture): - assert asyncio.get_event_loop() - - counter = 1 - - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) - - await asyncio.ensure_future(inc()) - assert counter == 2 - - - async def test_is_asyncio(event_loop, sample_fixture): - assert asyncio.get_event_loop() - counter = 1 - - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) - - await asyncio.ensure_future(inc()) - assert counter == 2 - - - @pytest.fixture - def sample_fixture(): - return None - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=2) - - -def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytestmark = pytest.mark.asyncio_event_loop - - loop: asyncio.AbstractEventLoop - - @pytest.mark.asyncio - async def test_remember_loop(): - global loop - loop = asyncio.get_running_loop() - - @pytest.mark.asyncio - async def test_this_runs_in_same_loop(): - global loop - assert asyncio.get_running_loop() is loop - - class TestClassA: - @pytest.mark.asyncio - async def test_this_runs_in_same_loop(self): - global loop - assert asyncio.get_running_loop() is loop - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=3) - - -def test_asyncio_mark_provides_class_scoped_loop_auto_mode(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytestmark = pytest.mark.asyncio_event_loop - - loop: asyncio.AbstractEventLoop - - async def test_remember_loop(): - global loop - loop = asyncio.get_running_loop() - - async def test_this_runs_in_same_loop(): - global loop - assert asyncio.get_running_loop() is loop - - class TestClassA: - async def test_this_runs_in_same_loop(self): - global loop - assert asyncio.get_running_loop() is loop - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=auto") - result.assert_outcomes(passed=3) - - -def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytestmark = pytest.mark.asyncio_event_loop - - @pytest.mark.asyncio - async def test_remember_loop(event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") - - -def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( - pytester: Pytester, -): - pytester.makepyfile( - __init__="", - custom_policy=dedent( - """\ - import asyncio - - class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - pass - """ - ), - test_uses_custom_policy=dedent( - """\ - import asyncio - import pytest - - from .custom_policy import CustomEventLoopPolicy - - pytestmark = pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) - - @pytest.mark.asyncio - async def test_uses_custom_event_loop_policy(): - assert isinstance( - asyncio.get_event_loop_policy(), - CustomEventLoopPolicy, - ) - """ - ), - test_does_not_use_custom_policy=dedent( - """\ - import asyncio - import pytest - - from .custom_policy import CustomEventLoopPolicy - - @pytest.mark.asyncio - async def test_does_not_use_custom_event_loop_policy(): - assert not isinstance( - asyncio.get_event_loop_policy(), - CustomEventLoopPolicy, - ) - """ - ), - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=2) - - -def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - - pytestmark = pytest.mark.asyncio_event_loop( - policy=[ - asyncio.DefaultEventLoopPolicy(), - asyncio.DefaultEventLoopPolicy(), - ] - ) - - @pytest.mark.asyncio - async def test_parametrized_loop(): - pass - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=2) - - -def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - import pytest_asyncio - - pytestmark = pytest.mark.asyncio_event_loop - - loop: asyncio.AbstractEventLoop - - @pytest_asyncio.fixture - async def my_fixture(): - global loop - loop = asyncio.get_running_loop() - - @pytest.mark.asyncio - async def test_runs_is_same_loop_as_fixture(my_fixture): - global loop - assert asyncio.get_running_loop() is loop - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=1) diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py new file mode 100644 index 00000000..a050f503 --- /dev/null +++ b/tests/markers/test_module_scope.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +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(loop_scope="module") + + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(): + global loop + loop = asyncio.get_running_loop() + + async def test_this_runs_in_same_loop(): + global loop + assert asyncio.get_running_loop() is loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +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( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="module") + + @pytest.fixture(scope="module") + def event_loop_policy(): + return CustomEventLoopPolicy() + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_does_not_use_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="module") + + async def test_does_not_use_custom_event_loop_policy(): + assert not isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_respects_parametrized_loop_policies( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio(loop_scope="module") + + @pytest.fixture( + scope="module", + params=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_module_scoped_loop_to_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + pytestmark = pytest.mark.asyncio(loop_scope="module") + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="module", scope="module") + async def my_fixture(): + global loop + loop = asyncio.get_running_loop() + + async def test_runs_is_same_loop_as_fixture(my_fixture): + global loop + assert asyncio.get_running_loop() is loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="module", scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="module", scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @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 + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="module", scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + yield + + @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 + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import asyncio + + @pytest.fixture(scope="module") + def sets_event_loop_to_none(): + # asyncio.run() creates a new event loop without closing the existing + # one. For any test, but the first one, this leads to a ResourceWarning + # when the discarded loop is destroyed by the garbage collector. + # We close the current loop to avoid this + try: + asyncio.get_event_loop().close() + except RuntimeError: + pass + return asyncio.run(asyncio.sleep(0)) + # asyncio.run() sets the current event loop to None when finished + + @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): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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(loop_scope="module") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py new file mode 100644 index 00000000..3e41459b --- /dev/null +++ b/tests/markers/test_package_scope.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +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( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_module_one=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + @pytest.mark.asyncio(loop_scope="package") + async def test_remember_loop(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + test_module_two=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(loop_scope="package") + + async def test_this_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is shared_module.loop + """ + ), + ) + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("__init__.py").touch() + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + 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 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=4) + + +def test_asyncio_mark_respects_the_loop_policy( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.fixture(scope="package") + def event_loop_policy(): + return CustomEventLoopPolicy() + """ + ), + custom_policy=dedent( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="package") + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_also_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="package") + + async def test_also_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio(loop_scope="package") + + @pytest.fixture( + scope="package", + params=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + pass + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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__="", + conftest=dedent( + f"""\ + import asyncio + + import pytest_asyncio + + from {package_name} import shared_module + + @pytest_asyncio.fixture(loop_scope="package", scope="package") + async def my_fixture(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_fixture_runs_in_scoped_loop=dedent( + f"""\ + import asyncio + + import pytest + import pytest_asyncio + + from {package_name} import shared_module + + 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 + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="package", scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @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 + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="package", scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="package", scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import asyncio + + @pytest.fixture(scope="package") + def sets_event_loop_to_none(): + # asyncio.run() creates a new event loop without closing the existing + # one. For any test, but the first one, this leads to a ResourceWarning + # when the discarded loop is destroyed by the garbage collector. + # We close the current loop to avoid this + try: + asyncio.get_event_loop().close() + except RuntimeError: + pass + return asyncio.run(asyncio.sleep(0)) + # asyncio.run() sets the current event loop to None when finished + + @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): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py new file mode 100644 index 00000000..2d3a4993 --- /dev/null +++ b/tests/markers/test_session_scope.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +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( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_module_one=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + @pytest.mark.asyncio(loop_scope="session") + async def test_remember_loop(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + test_module_two=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(loop_scope="session") + + async def test_this_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is shared_module.loop + """ + ), + ) + + # subpackage_name must alphabetically come after test_module_one.py + subpackage_name = "z_subpkg" + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(loop_scope="session") + + async def test_subpackage_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=4) + + +def test_asyncio_mark_respects_the_loop_policy( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.fixture(scope="session") + def event_loop_policy(): + return CustomEventLoopPolicy() + """ + ), + custom_policy=dedent( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="session") + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_also_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(loop_scope="session") + + async def test_also_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio(loop_scope="session") + + @pytest.fixture( + scope="session", + params=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + pass + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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( + f"""\ + import asyncio + + import pytest_asyncio + + from {package_name} import shared_module + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def my_fixture(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + ) + subpackage_name = "subpkg" + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + + import pytest + import pytest_asyncio + + from {package_name} import shared_module + + 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 + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @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 + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @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 + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(loop_scope="session", scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + yield + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import asyncio + + @pytest.fixture(scope="session") + def sets_event_loop_to_none(): + # asyncio.run() creates a new event loop without closing the existing + # one. For any test, but the first one, this leads to a ResourceWarning + # when the discarded loop is destroyed by the garbage collector. + # We close the current loop to avoid this + try: + asyncio.get_event_loop().close() + except RuntimeError: + pass + return asyncio.run(asyncio.sleep(0)) + # asyncio.run() sets the current event loop to None when finished + + @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): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +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(loop_scope="session") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/modes/__init__.py b/tests/modes/__init__.py new file mode 100644 index 00000000..e69de29b 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 3b6487c7..d7dc4ac6 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, version_tuple as pytest_version + -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,5 +78,149 @@ 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(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") + if pytest_version >= (8, 4, 0): + result.assert_outcomes(failed=1, skipped=0, warnings=0) + else: + 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(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(): + raise RuntimeError() + + async def test_anything(any_fixture): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") + + if pytest_version >= (8, 4, 0): + result.assert_outcomes(failed=1, skipped=0, warnings=2) + else: + result.assert_outcomes(skipped=1, warnings=2) + result.stdout.fnmatch_lines( + [ + "*async def functions are not natively supported*", + "*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") + if pytest_version >= (8, 4, 0): + result.assert_outcomes(passed=1, failed=0, skipped=0, warnings=2) + else: + 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 pytest-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") + if pytest_version >= (8, 4, 0): + result.assert_outcomes(passed=1, warnings=2) + else: + 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 pytest-asyncio." + ), + ], + ) diff --git a/tests/test_asyncio_debug.py b/tests/test_asyncio_debug.py new file mode 100644 index 00000000..b097b63c --- /dev/null +++ b/tests/test_asyncio_debug.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from textwrap import dedent + +import pytest +from pytest import Pytester + + +def test_asyncio_debug_disabled_by_default(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_debug_mode_disabled(): + loop = asyncio.get_running_loop() + assert not loop.get_debug() + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_asyncio_debug_enabled_via_cli_option(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_debug_mode_enabled(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + """ + ) + ) + result = pytester.runpytest("--asyncio-debug") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("config_value", ("true", "1")) +def test_asyncio_debug_enabled_via_config_option(pytester: Pytester, config_value: str): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_debug = {config_value} + """ + ) + ) + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_debug_mode_enabled(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("config_value", ("false", "0")) +def test_asyncio_debug_disabled_via_config_option( + pytester: Pytester, + config_value: str, +): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_debug = {config_value} + """ + ) + ) + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_debug_mode_disabled(): + loop = asyncio.get_running_loop() + assert not loop.get_debug() + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_asyncio_debug_cli_option_overrides_config(pytester: Pytester): + pytester.makeini( + "[pytest]\nasyncio_default_fixture_loop_scope = function\nasyncio_debug = false" + ) + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_debug_mode_enabled(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + """ + ) + ) + result = pytester.runpytest("--asyncio-debug") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("loop_scope", ("function", "module", "session")) +def test_asyncio_debug_with_different_loop_scopes(pytester: Pytester, loop_scope: str): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_debug_mode_with_scope(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + """ + ) + ) + result = pytester.runpytest("--asyncio-debug") + result.assert_outcomes(passed=1) + + +def test_asyncio_debug_with_async_fixtures(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 async_fixture(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + return "fixture_value" + + @pytest.mark.asyncio + async def test_debug_mode_with_fixture(async_fixture): + loop = asyncio.get_running_loop() + assert loop.get_debug() + assert async_fixture == "fixture_value" + """ + ) + ) + result = pytester.runpytest("--asyncio-debug") + result.assert_outcomes(passed=1) + + +def test_asyncio_debug_multiple_test_functions(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_debug_first(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + + @pytest.mark.asyncio + async def test_debug_second(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + + @pytest.mark.asyncio + async def test_debug_third(): + loop = asyncio.get_running_loop() + assert loop.get_debug() + """ + ) + ) + result = pytester.runpytest("--asyncio-debug") + result.assert_outcomes(passed=3) 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 b514cbcd..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 @@ -15,7 +17,7 @@ def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1) result.stdout.fnmatch_lines( ["*is marked with '@pytest.mark.asyncio' but it is not an async function.*"] @@ -36,7 +38,7 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(xfailed=1, warnings=1) result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] @@ -54,7 +56,7 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=auto", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=auto", "-W default") result.assert_outcomes(xfailed=1, warnings=1) result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] @@ -76,7 +78,7 @@ async def test_a(self): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(xfailed=1, warnings=1) result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] @@ -96,7 +98,7 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=auto", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=auto", "-W default") result.assert_outcomes(xfailed=1, warnings=1) result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] @@ -119,7 +121,7 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W default") result.assert_outcomes(xfailed=1, warnings=1) result.stdout.fnmatch_lines( ["*Tests based on asynchronous generators are not supported*"] @@ -139,8 +141,85 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-mode=auto", "-W default") + result = pytester.runpytest_subprocess("--asyncio-mode=auto", "-W default") result.assert_outcomes(xfailed=1, warnings=1) 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 deleted file mode 100644 index dc70fe9c..00000000 --- a/tests/test_dependent_fixtures.py +++ /dev/null @@ -1,14 +0,0 @@ -import asyncio - -import pytest - - -@pytest.mark.asyncio -async def test_dependent_fixture(dependent_fixture): - """Test a dependent fixture.""" - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_factory_involving_factories(factory_involving_factories): - factory_involving_factories() diff --git a/tests/test_doctest.py b/tests/test_doctest.py new file mode 100644 index 00000000..d175789e --- /dev/null +++ b/tests/test_doctest.py @@ -0,0 +1,43 @@ +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( + '''\ + def any_function(): + """ + >>> 42 + 42 + """ + ''' + ), + ) + result = pytester.runpytest("--asyncio-mode=strict", "--doctest-modules") + result.assert_outcomes(passed=1) + + +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__="", + test_python_file=dedent( + """\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_anything(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/test_event_loop_fixture.py b/tests/test_event_loop_fixture.py index aaf591c9..8b9ac634 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,91 @@ 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_closing_event_loop_in_sync_fixture_teardown_raises_warning( + 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 close_event_loop(_event_loop): + yield + # fixture has its own cleanup code + _event_loop.close() + + @pytest.mark.asyncio + async def test_something(close_event_loop): + await asyncio.sleep(0.01) + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines( + ["*An exception occurred during teardown of an asyncio.Runner*"] + ) + + +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 deleted file mode 100644 index 5f8f9574..00000000 --- a/tests/test_event_loop_fixture_finalizer.py +++ /dev/null @@ -1,137 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - - loop = asyncio.get_event_loop_policy().get_event_loop() - - @pytest.mark.asyncio - async def test_1(): - # This async test runs in its own event loop - global loop - running_loop = asyncio.get_event_loop_policy().get_event_loop() - # Make sure this test case received a different loop - assert running_loop is not loop - - def test_2(): - # Code outside of pytest-asyncio should not receive a "used" event loop - current_loop = asyncio.get_event_loop_policy().get_event_loop() - assert not current_loop.is_running() - assert not current_loop.is_closed() - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=2) - - -def test_event_loop_fixture_finalizer_handles_loop_set_to_none_sync( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - - def test_sync(event_loop): - asyncio.get_event_loop_policy().set_event_loop(None) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - -def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_without_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.mark.asyncio - async def test_async_without_explicit_fixture_request(): - asyncio.get_event_loop_policy().set_event_loop(None) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - -def test_event_loop_fixture_finalizer_handles_loop_set_to_none_async_with_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.mark.asyncio - async def test_async_with_explicit_fixture_request(event_loop): - asyncio.get_event_loop_policy().set_event_loop(None) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - -def test_event_loop_fixture_finalizer_raises_warning_when_fixture_leaves_loop_unclosed( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = 'pytest_asyncio' - - @pytest.fixture - def event_loop(): - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - - @pytest.mark.asyncio - async def test_ends_with_unclosed_loop(): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict", "-W", "default") - result.assert_outcomes(passed=1, warnings=2) - result.stdout.fnmatch_lines("*unclosed event loop*") - - -def test_event_loop_fixture_finalizer_raises_warning_when_test_leaves_loop_unclosed( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = 'pytest_asyncio' - - @pytest.mark.asyncio - async def test_ends_with_unclosed_loop(): - asyncio.set_event_loop(asyncio.new_event_loop()) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict", "-W", "default") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines("*unclosed event loop*") diff --git a/tests/test_event_loop_fixture_override_deprecation.py b/tests/test_event_loop_fixture_override_deprecation.py deleted file mode 100644 index 3484ef76..00000000 --- a/tests/test_event_loop_fixture_override_deprecation.py +++ /dev/null @@ -1,111 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_emit_warning_when_event_loop_fixture_is_redefined(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.fixture - def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest.mark.asyncio - async def test_emits_warning(): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ["*event_loop fixture provided by pytest-asyncio has been redefined*"] - ) - - -def test_emit_warning_when_event_loop_fixture_is_redefined_explicit_request( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.fixture - def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest.mark.asyncio - async def test_emits_warning_when_requested_explicitly(event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=2) - result.stdout.fnmatch_lines( - ["*event_loop fixture provided by pytest-asyncio has been redefined*"] - ) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_does_not_emit_warning_when_no_test_uses_the_event_loop_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - @pytest.fixture - def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - def test_emits_no_warning(): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=0) - - -def test_emit_warning_when_redefined_event_loop_is_used_by_fixture(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - import pytest_asyncio - - @pytest.fixture - def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest_asyncio.fixture - async def uses_event_loop(): - pass - - def test_emits_warning(uses_event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) diff --git a/tests/test_explicit_event_loop_fixture_request.py b/tests/test_explicit_event_loop_fixture_request.py deleted file mode 100644 index 8c4b732c..00000000 --- a/tests/test_explicit_event_loop_fixture_request.py +++ /dev/null @@ -1,159 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - - @pytest.mark.asyncio - async def test_coroutine_emits_warning(event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_method( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - - class TestEmitsWarning: - @pytest.mark.asyncio - async def test_coroutine_emits_warning(self, event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_staticmethod( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - - class TestEmitsWarning: - @staticmethod - @pytest.mark.asyncio - async def test_coroutine_emits_warning(event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - import pytest_asyncio - - @pytest_asyncio.fixture - async def emits_warning(event_loop): - pass - - @pytest.mark.asyncio - async def test_uses_fixture(emits_warning): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_emit_warning_when_event_loop_is_explicitly_requested_in_async_gen_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - import pytest_asyncio - - @pytest_asyncio.fixture - async def emits_warning(event_loop): - yield - - @pytest.mark.asyncio - async def test_uses_fixture(emits_warning): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) - - -def test_does_not_emit_warning_when_event_loop_is_explicitly_requested_in_sync_function( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - - def test_uses_fixture(event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - -def test_does_not_emit_warning_when_event_loop_is_explicitly_requested_in_sync_fixture( - pytester: Pytester, -): - pytester.makepyfile( - dedent( - """\ - import pytest - - @pytest.fixture - def any_fixture(event_loop): - pass - - def test_uses_fixture(any_fixture): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) diff --git a/tests/test_fixture_loop_scopes.py b/tests/test_fixture_loop_scopes.py new file mode 100644 index 00000000..95e46818 --- /dev/null +++ b/tests/test_fixture_loop_scopes.py @@ -0,0 +1,155 @@ +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) + + +def test_invalid_default_fixture_loop_scope_raises_error(pytester: Pytester): + pytester.makeini( + """\ + [pytest] + asyncio_default_fixture_loop_scope = invalid_scope + """ + ) + result = pytester.runpytest() + result.stderr.fnmatch_lines( + [ + "ERROR: 'invalid_scope' is not a valid " + "asyncio_default_fixture_loop_scope. Valid scopes are: " + "function, class, module, package, session." + ] + ) diff --git a/tests/test_flaky_integration.py b/tests/test_flaky_integration.py deleted file mode 100644 index 54c9d2ea..00000000 --- a/tests/test_flaky_integration.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for the Flaky integration, which retries failed tests. -""" -from textwrap import dedent - - -def test_auto_mode_cmdline(testdir): - testdir.makepyfile( - dedent( - """\ - import asyncio - import flaky - import pytest - - _threshold = -1 - - @flaky.flaky(3, 2) - @pytest.mark.asyncio - async def test_asyncio_flaky_thing_that_fails_then_succeeds(): - global _threshold - await asyncio.sleep(0.1) - _threshold += 1 - assert _threshold != 1 - """ - ) - ) - # runpytest_subprocess() is required to don't pollute the output - # with flaky restart information - result = testdir.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - result.stdout.fnmatch_lines( - [ - "===Flaky Test Report===", - "test_asyncio_flaky_thing_that_fails_then_succeeds passed 1 " - "out of the required 2 times. Running test again until it passes 2 times.", - "test_asyncio_flaky_thing_that_fails_then_succeeds failed " - "(1 runs remaining out of 3).", - " ", - " assert 1 != 1", - "test_asyncio_flaky_thing_that_fails_then_succeeds passed 2 " - "out of the required 2 times. Success!", - "===End Flaky Test Report===", - ] - ) diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 00000000..2272704a --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,63 @@ +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( + """\ + raise ImportWarning() + + async def test_errors_out(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(errors=1) + + +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( + """\ + raise ImportWarning() + """ + ), + test_a=dedent( + """\ + async def test_errors_out(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(errors=1) + + +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( + """\ + raise ImportError() + """ + ), + ) + test_dir = pytester.mkdir("tests") + test_dir.joinpath("test_a.py").write_text( + dedent( + """\ + async def test_passes(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py new file mode 100644 index 00000000..f99dc0d9 --- /dev/null +++ b/tests/test_is_async_test.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from textwrap import dedent + +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( + """\ + import pytest + import pytest_asyncio + + def test_sync(): + pass + + def pytest_collection_modifyitems(items): + async_tests = [ + item + for item in items + if pytest_asyncio.is_async_test(item) + ] + assert len(async_tests) == 0 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import pytest_asyncio + + @pytest.mark.asyncio + async def test_coro(): + pass + + def pytest_collection_modifyitems(items): + async_tests = [ + item + for item in items + if pytest_asyncio.is_async_test(item) + ] + assert len(async_tests) == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +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( + """\ + import pytest + import pytest_asyncio + + async def test_coro(): + pass + + def pytest_collection_modifyitems(items): + async_tests = [ + item + for item in items + if pytest_asyncio.is_async_test(item) + ] + assert len(async_tests) == 0 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + 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( + """\ + import pytest + import pytest_asyncio + + async def test_coro(): + pass + + def pytest_collection_modifyitems(items): + async_tests = [ + item + for item in items + if pytest_asyncio.is_async_test(item) + ] + assert len(async_tests) == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/test_multiloop.py b/tests/test_multiloop.py deleted file mode 100644 index c3713cc9..00000000 --- a/tests/test_multiloop.py +++ /dev/null @@ -1,70 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_event_loop_override(pytester: Pytester): - pytester.makeconftest( - dedent( - '''\ - import asyncio - - import pytest - - - @pytest.fixture - def dependent_fixture(event_loop): - """A fixture dependent on the event_loop fixture, doing some cleanup.""" - counter = 0 - - async def just_a_sleep(): - """Just sleep a little while.""" - nonlocal event_loop - await asyncio.sleep(0.1) - nonlocal counter - counter += 1 - - event_loop.run_until_complete(just_a_sleep()) - yield - event_loop.run_until_complete(just_a_sleep()) - - assert counter == 2 - - - class CustomSelectorLoop(asyncio.SelectorEventLoop): - """A subclass with no overrides, just to test for presence.""" - - - @pytest.fixture - def event_loop(): - """Create an instance of the default event loop for each test case.""" - loop = CustomSelectorLoop() - yield loop - loop.close() - ''' - ) - ) - pytester.makepyfile( - dedent( - '''\ - """Unit tests for overriding the event loop.""" - import asyncio - - import pytest - - - @pytest.mark.asyncio - async def test_for_custom_loop(): - """This test should be executed using the custom loop.""" - await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" - - - @pytest.mark.asyncio - async def test_dependent_fixture(dependent_fixture): - await asyncio.sleep(0.1) - ''' - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=2, warnings=2) diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 00000000..6f4f720d --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,5 @@ +import pytest_asyncio + + +def test_package_exposes_version(): + assert pytest_asyncio.__version__ diff --git a/tests/test_port_factories.py b/tests/test_port_factories.py new file mode 100644 index 00000000..713d747e --- /dev/null +++ b/tests/test_port_factories.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + +import pytest_asyncio.plugin + + +def test_unused_tcp_port_selects_unused_port(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + @pytest.mark.asyncio + async def test_unused_port_fixture(unused_tcp_port): + async def closer(_, writer): + writer.close() + + server1 = await asyncio.start_server( + closer, host="localhost", port=unused_tcp_port + ) + + with pytest.raises(IOError): + await asyncio.start_server( + closer, host="localhost", port=unused_tcp_port + ) + + server1.close() + await server1.wait_closed() + """ + ) + ) + + +def test_unused_udp_port_selects_unused_port(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + @pytest.mark.asyncio + async def test_unused_udp_port_fixture(unused_udp_port): + class Closer: + def connection_made(self, transport): + pass + + def connection_lost(self, *arg, **kwd): + pass + + event_loop = asyncio.get_running_loop() + transport1, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", unused_udp_port), + reuse_port=False, + ) + + with pytest.raises(IOError): + await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", unused_udp_port), + reuse_port=False, + ) + + transport1.abort() + """ + ) + ) + + +def test_unused_tcp_port_factory_selects_unused_port(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + @pytest.mark.asyncio + async def test_unused_port_factory_fixture(unused_tcp_port_factory): + async def closer(_, writer): + writer.close() + + port1, port2, port3 = ( + unused_tcp_port_factory(), + unused_tcp_port_factory(), + unused_tcp_port_factory(), + ) + + server1 = await asyncio.start_server( + closer, host="localhost", port=port1 + ) + server2 = await asyncio.start_server( + closer, host="localhost", port=port2 + ) + server3 = await asyncio.start_server( + closer, host="localhost", port=port3 + ) + + for port in port1, port2, port3: + with pytest.raises(IOError): + await asyncio.start_server(closer, host="localhost", port=port) + + server1.close() + await server1.wait_closed() + server2.close() + await server2.wait_closed() + server3.close() + await server3.wait_closed() + """ + ) + ) + + +def test_unused_udp_port_factory_selects_unused_port(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + @pytest.mark.asyncio + async def test_unused_udp_port_factory_fixture(unused_udp_port_factory): + class Closer: + def connection_made(self, transport): + pass + + def connection_lost(self, *arg, **kwd): + pass + + port1, port2, port3 = ( + unused_udp_port_factory(), + unused_udp_port_factory(), + unused_udp_port_factory(), + ) + + event_loop = asyncio.get_running_loop() + transport1, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port1), + reuse_port=False, + ) + transport2, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port2), + reuse_port=False, + ) + transport3, _ = await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port3), + reuse_port=False, + ) + + for port in port1, port2, port3: + with pytest.raises(IOError): + await event_loop.create_datagram_endpoint( + Closer, + local_addr=("127.0.0.1", port), + reuse_port=False, + ) + + transport1.abort() + transport2.abort() + transport3.abort() + """ + ) + ) + + +def test_unused_port_factory_duplicate(unused_tcp_port_factory, monkeypatch): + """Test correct avoidance of duplicate ports.""" + counter = 0 + + def mock_unused_tcp_port(_ignored): + """Force some duplicate ports.""" + nonlocal counter + counter += 1 + if counter < 5: + return 10000 + else: + return 10000 + counter + + monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_tcp_port) + + assert unused_tcp_port_factory() == 10000 + assert unused_tcp_port_factory() > 10000 + + +def test_unused_udp_port_factory_duplicate(unused_udp_port_factory, monkeypatch): + """Test correct avoidance of duplicate UDP ports.""" + counter = 0 + + def mock_unused_udp_port(_ignored): + """Force some duplicate ports.""" + nonlocal counter + counter += 1 + if counter < 5: + return 10000 + else: + return 10000 + counter + + monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_udp_port) + + assert unused_udp_port_factory() == 10000 + assert unused_udp_port_factory() > 10000 diff --git a/tests/test_set_event_loop.py b/tests/test_set_event_loop.py new file mode 100644 index 00000000..20037b48 --- /dev/null +++ b/tests/test_set_event_loop.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + + +@pytest.mark.parametrize( + "test_loop_scope", + ("function", "module", "package", "session"), +) +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_set_event_loop_none( + pytester: Pytester, + test_loop_scope: str, + loop_breaking_action: str, +): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_test_loop_scope = {test_loop_scope} + asyncio_default_fixture_loop_scope = function + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_before(): + pass + + def test_set_event_loop_none(): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_after(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=3) + + +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_set_event_loop_none_class(pytester: Pytester, loop_breaking_action: str): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_test_loop_scope = class + asyncio_default_fixture_loop_scope = function + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + + class TestClass: + @pytest.mark.asyncio + async def test_before(self): + pass + + def test_set_event_loop_none(self): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_after(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=3) + + +@pytest.mark.parametrize("test_loop_scope", ("module", "package", "session")) +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_original_shared_loop_is_reinstated_not_fresh_loop( + pytester: Pytester, + test_loop_scope: str, + loop_breaking_action: str, +): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_test_loop_scope = {test_loop_scope} + asyncio_default_fixture_loop_scope = function + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + original_shared_loop: asyncio.AbstractEventLoop = None + + @pytest.mark.asyncio + async def test_store_original_shared_loop(): + global original_shared_loop + original_shared_loop = asyncio.get_running_loop() + original_shared_loop._custom_marker = "original_loop_marker" + + def test_unset_event_loop(): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_verify_original_loop_reinstated(): + global original_shared_loop + current_loop = asyncio.get_running_loop() + assert current_loop is original_shared_loop + assert hasattr(current_loop, '_custom_marker') + assert current_loop._custom_marker == "original_loop_marker" + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +@pytest.mark.parametrize("test_loop_scope", ("module", "package", "session")) +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_shared_loop_with_fixture_preservation( + pytester: Pytester, + test_loop_scope: str, + loop_breaking_action: str, +): + pytester.makeini( + dedent( + f"""\ + [pytest] + asyncio_default_test_loop_scope = {test_loop_scope} + asyncio_default_fixture_loop_scope = {test_loop_scope} + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + fixture_loop: asyncio.AbstractEventLoop = None + long_running_task = None + + @pytest_asyncio.fixture + async def webserver(): + global fixture_loop, long_running_task + fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(1) + + long_running_task = asyncio.create_task(background_task()) + yield + long_running_task.cancel() + + + @pytest.mark.asyncio + async def test_before(webserver): + global fixture_loop, long_running_task + assert asyncio.get_running_loop() is fixture_loop + assert not long_running_task.done() + + + def test_set_event_loop_none(): + {loop_breaking_action} + + + @pytest.mark.asyncio + async def test_after(webserver): + global fixture_loop, long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is fixture_loop + assert not long_running_task.done() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +@pytest.mark.parametrize( + "first_scope,second_scope", + [ + ("module", "session"), + ("session", "module"), + ("package", "session"), + ("session", "package"), + ("package", "module"), + ("module", "package"), + ], +) +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_shared_loop_with_multiple_fixtures_preservation( + pytester: Pytester, + first_scope: str, + second_scope: str, + loop_breaking_action: str, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_test_loop_scope = session + asyncio_default_fixture_loop_scope = session + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + first_fixture_loop: asyncio.AbstractEventLoop = None + second_fixture_loop: asyncio.AbstractEventLoop = None + first_long_running_task = None + second_long_running_task = None + + @pytest_asyncio.fixture(scope="{first_scope}", loop_scope="{first_scope}") + async def first_webserver(): + global first_fixture_loop, first_long_running_task + first_fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(0.1) + + first_long_running_task = asyncio.create_task(background_task()) + yield + first_long_running_task.cancel() + + @pytest_asyncio.fixture(scope="{second_scope}", loop_scope="{second_scope}") + async def second_webserver(): + global second_fixture_loop, second_long_running_task + second_fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(0.1) + + second_long_running_task = asyncio.create_task(background_task()) + yield + second_long_running_task.cancel() + + @pytest.mark.asyncio(loop_scope="{first_scope}") + async def test_before_first(first_webserver): + global first_fixture_loop, first_long_running_task + assert asyncio.get_running_loop() is first_fixture_loop + assert not first_long_running_task.done() + + @pytest.mark.asyncio(loop_scope="{second_scope}") + async def test_before_second(second_webserver): + global second_fixture_loop, second_long_running_task + assert asyncio.get_running_loop() is second_fixture_loop + assert not second_long_running_task.done() + + def test_set_event_loop_none(): + {loop_breaking_action} + + @pytest.mark.asyncio(loop_scope="{first_scope}") + async def test_after_first(first_webserver): + global first_fixture_loop, first_long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is first_fixture_loop + assert not first_long_running_task.done() + + @pytest.mark.asyncio(loop_scope="{second_scope}") + async def test_after_second(second_webserver): + global second_fixture_loop, second_long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is second_fixture_loop + assert not second_long_running_task.done() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=5) diff --git a/tests/test_simple.py b/tests/test_simple.py index b6020c69..f92ef4e7 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,25 +1,19 @@ """Quick'n'dirty unit tests for provided fixtures and markers.""" + +from __future__ import annotations + import asyncio from textwrap import dedent import pytest from pytest import Pytester -import pytest_asyncio.plugin - async def async_coro(): await asyncio.sleep(0) return "ok" -def test_event_loop_fixture(event_loop): - """Test the injection of the event_loop fixture.""" - assert event_loop - ret = event_loop.run_until_complete(async_coro()) - assert ret == "ok" - - @pytest.mark.asyncio async def test_asyncio_marker(): """Test the asyncio pytest marker.""" @@ -27,6 +21,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( """\ @@ -46,6 +41,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( """\ @@ -69,171 +65,15 @@ async def test_asyncio_marker_with_default_param(a_param=None): await asyncio.sleep(0) -@pytest.mark.asyncio -async def test_unused_port_fixture(unused_tcp_port): - """Test the unused TCP port fixture.""" - - async def closer(_, writer): - writer.close() - - server1 = await asyncio.start_server(closer, host="localhost", port=unused_tcp_port) - - with pytest.raises(IOError): - await asyncio.start_server(closer, host="localhost", port=unused_tcp_port) - - server1.close() - await server1.wait_closed() - - -@pytest.mark.asyncio -async def test_unused_udp_port_fixture(unused_udp_port): - """Test the unused TCP port fixture.""" - - class Closer: - def connection_made(self, transport): - pass - - def connection_lost(self, *arg, **kwd): - pass - - event_loop = asyncio.get_running_loop() - transport1, _ = await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", unused_udp_port), - reuse_port=False, - ) - - with pytest.raises(IOError): - await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", unused_udp_port), - reuse_port=False, - ) - - transport1.abort() - - -@pytest.mark.asyncio -async def test_unused_port_factory_fixture(unused_tcp_port_factory): - """Test the unused TCP port factory fixture.""" - - async def closer(_, writer): - writer.close() - - port1, port2, port3 = ( - unused_tcp_port_factory(), - unused_tcp_port_factory(), - unused_tcp_port_factory(), - ) - - server1 = await asyncio.start_server(closer, host="localhost", port=port1) - server2 = await asyncio.start_server(closer, host="localhost", port=port2) - server3 = await asyncio.start_server(closer, host="localhost", port=port3) - - for port in port1, port2, port3: - with pytest.raises(IOError): - await asyncio.start_server(closer, host="localhost", port=port) - - server1.close() - await server1.wait_closed() - server2.close() - await server2.wait_closed() - server3.close() - await server3.wait_closed() - - -@pytest.mark.asyncio -async def test_unused_udp_port_factory_fixture(unused_udp_port_factory): - """Test the unused UDP port factory fixture.""" - - class Closer: - def connection_made(self, transport): - pass - - def connection_lost(self, *arg, **kwd): - pass - - port1, port2, port3 = ( - unused_udp_port_factory(), - unused_udp_port_factory(), - unused_udp_port_factory(), - ) - - event_loop = asyncio.get_running_loop() - transport1, _ = await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", port1), - reuse_port=False, - ) - transport2, _ = await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", port2), - reuse_port=False, - ) - transport3, _ = await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", port3), - reuse_port=False, - ) - - for port in port1, port2, port3: - with pytest.raises(IOError): - await event_loop.create_datagram_endpoint( - Closer, - local_addr=("127.0.0.1", port), - reuse_port=False, - ) - - transport1.abort() - transport2.abort() - transport3.abort() - - -def test_unused_port_factory_duplicate(unused_tcp_port_factory, monkeypatch): - """Test correct avoidance of duplicate ports.""" - counter = 0 - - def mock_unused_tcp_port(_ignored): - """Force some duplicate ports.""" - nonlocal counter - counter += 1 - if counter < 5: - return 10000 - else: - return 10000 + counter - - monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_tcp_port) - - assert unused_tcp_port_factory() == 10000 - assert unused_tcp_port_factory() > 10000 - - -def test_unused_udp_port_factory_duplicate(unused_udp_port_factory, monkeypatch): - """Test correct avoidance of duplicate UDP ports.""" - counter = 0 - - def mock_unused_udp_port(_ignored): - """Force some duplicate ports.""" - nonlocal counter - counter += 1 - if counter < 5: - return 10000 - else: - return 10000 + counter - - monkeypatch.setattr(pytest_asyncio.plugin, "_unused_port", mock_unused_udp_port) - - assert unused_udp_port_factory() == 10000 - assert unused_udp_port_factory() > 10000 - - class TestMarkerInClassBasedTests: """Test that asyncio marked functions work for methods of test classes.""" @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" @@ -260,43 +100,9 @@ async def test_event_loop_before_fixture(self, loop): assert await loop.run_in_executor(None, self.foo) == 1 -def test_asyncio_marker_compatibility_with_skip(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import pytest - - pytest_plugins = "pytest_asyncio" - - @pytest.mark.asyncio - async def test_no_warning_on_skip(): - pytest.skip("Test a skip error inside asyncio") - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(skipped=1) - - -def test_asyncio_auto_mode_compatibility_with_skip(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import pytest - - pytest_plugins = "pytest_asyncio" - - async def test_no_warning_on_skip(): - pytest.skip("Test a skip error inside asyncio") - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=auto") - result.assert_outcomes(skipped=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 new file mode 100644 index 00000000..d32273cd --- /dev/null +++ b/tests/test_skips.py @@ -0,0 +1,146 @@ +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( + """\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_no_warning_on_skip(): + pytest.skip("Test a skip error inside asyncio") + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(skipped=1) + + +def test_asyncio_auto_mode_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + pytest_plugins = "pytest_asyncio" + + async def test_no_warning_on_skip(): + pytest.skip("Test a skip error inside asyncio") + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(skipped=1) + + +def test_asyncio_strict_mode_module_level_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + pytest.skip("Skip all tests", allow_module_level=True) + + @pytest.mark.asyncio + async def test_is_skipped(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(skipped=1) + + +def test_asyncio_auto_mode_module_level_skip(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + pytest.skip("Skip all tests", allow_module_level=True) + + async def test_is_skipped(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(skipped=1) + + +def test_asyncio_auto_mode_wrong_skip_usage(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import pytest + + pytest.skip("Skip all tests") + + async def test_is_skipped(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(errors=1) + + +def test_unittest_skiptest_compatibility(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + from unittest import SkipTest + + raise SkipTest("Skip all tests") + + async def test_is_skipped(): + pass + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(skipped=1) + + +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( + """\ + import pytest + + pytest.skip("Skip all tests", allow_module_level=True) + + def test_a(): + pass + + def test_b(): + pass + """ + ), + test_something=dedent( + """\ + import pytest + + @pytest.mark.asyncio + async def test_something(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1, skipped=1) diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index 14d3498a..438f49f2 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -1,19 +1,12 @@ """Tests for using subprocesses in tests.""" + +from __future__ import annotations + import asyncio.subprocess import sys import pytest -if sys.platform == "win32": - # The default asyncio event loop implementation on Windows does not - # support subprocesses. Subprocesses are available for Windows if a - # ProactorEventLoop is used. - @pytest.yield_fixture() - def event_loop(): - loop = asyncio.ProactorEventLoop() - yield loop - loop.close() - @pytest.mark.asyncio async def test_subprocess(): diff --git a/tests/test_task_cleanup.py b/tests/test_task_cleanup.py new file mode 100644 index 00000000..eb1f7d3c --- /dev/null +++ b/tests/test_task_cleanup.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_task_is_cancelled_when_abandoned_by_test(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio + async def test_create_task(): + async def coroutine(): + try: + while True: + await asyncio.sleep(0) + finally: + raise RuntimeError("The task should be cancelled at this point.") + + asyncio.create_task(coroutine()) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/trio/test_fixtures.py b/tests/trio/test_fixtures.py deleted file mode 100644 index 42b28437..00000000 --- a/tests/trio/test_fixtures.py +++ /dev/null @@ -1,25 +0,0 @@ -from textwrap import dedent - - -def test_strict_mode_ignores_trio_fixtures(testdir): - testdir.makepyfile( - dedent( - """\ - import pytest - import pytest_asyncio - import pytest_trio - - pytest_plugins = ["pytest_asyncio", "pytest_trio"] - - @pytest_trio.trio_fixture - async def any_fixture(): - return True - - @pytest.mark.trio - async def test_anything(any_fixture): - pass - """ - ) - ) - result = testdir.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) 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 33e0c931..b6121581 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,99 @@ [tox] -minversion = 3.14.0 -envlist = py38, py39, py310, py311, py312, pytest-min +minversion = 4.28.0 +envlist = build, py39, py310, py311, py312, py313, py314, pytest-min, docs, pyright isolated_build = true passenv = CI +[pkgenv] +constraints = constraints.txt + [testenv] +package = external extras = testing -deps = - --requirement dependencies/default/requirements.txt - --constraint dependencies/default/constraints.txt +constraints = constraints.txt commands = make test allowlist_externals = make +[testenv:.pkg_external] +deps = build +package_glob = {toxinidir}{/}dist{/}*.whl +commands = + python -c 'import shutil; shutil.rmtree("{toxinidir}{/}dist", ignore_errors=True)' + pyproject-build --outdir {toxinidir}{/}dist . + +[testenv:build] +description = Check distribution files +deps = + check-wheel-contents + twine +commands = + check-wheel-contents {toxinidir}{/}dist + twine check {toxinidir}{/}dist{/}* + [testenv:pytest-min] extras = testing -deps = - --requirement dependencies/pytest-min/requirements.txt - --constraint dependencies/pytest-min/constraints.txt +constraints = dependencies/pytest-min/constraints.txt +deps = -r dependencies/pytest-min/requirements.txt commands = make test allowlist_externals = make +[testenv:docs] +allowlist_externals = + git +extras = docs +change_dir = docs +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 + +[testenv:pyright] +deps = + pyright[nodejs] + pytest +commands = pyright pytest_asyncio/ +skip_install = true + [gh-actions] python = - 3.8: py38, pytest-min - 3.9: py39 + 3.9: py39, pytest-min, build 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313, pyright + 3.14-dev: py314 pypy3: pypy3